@redue/cli 0.1.0-alpha.16 → 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 +97 -0
- package/README.md +1 -1
- package/bin/redue.mjs +25 -14
- 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 +29 -7
- package/docs/user/tests-direct.md +28 -4
- package/package.json +2 -2
- package/src/agent-integration.mjs +2 -2
- package/src/claude-hook.mjs +100 -27
- package/src/cli.mjs +9 -4
- package/src/daemon.mjs +21 -7
- package/src/declared-plan.mjs +11 -4
- 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 +29 -12
- package/src/installed-inputs.mjs +4 -0
- package/src/node-onboarding.mjs +90 -47
- 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 +111 -52
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/test-direct.mjs +198 -36
- package/src/test-recorder.cjs +3 -2
- 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 +22 -0
package/src/node-onboarding.mjs
CHANGED
|
@@ -3,16 +3,17 @@ import {testProject,testRecipeScript,testCheckInputs,testsQualification} from '.
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import {spawnSync} from 'node:child_process';
|
|
6
|
-
import micromatch from './glob.mjs';
|
|
7
6
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
8
7
|
import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
9
8
|
import {realObservedPath} from './path-identity.mjs';
|
|
10
9
|
import {findExecutable} from './executable-lookup.mjs';
|
|
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,27 +162,25 @@ 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
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const dir=path.posix.dirname(file);
|
|
158
|
-
return dir!=='.'&&!dir.split('/').includes('node_modules')&&
|
|
159
|
-
micromatch.isMatch(dir,patterns,{dot:true});
|
|
160
|
-
}).sort();
|
|
161
|
-
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
162
|
-
return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
|
|
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)])]};
|
|
163
184
|
}
|
|
164
185
|
// Workspace packages as init finds them, with their scripts; [] without workspaces.
|
|
165
186
|
export function workspacePackages(root){
|
|
@@ -192,24 +213,33 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
192
213
|
for(const [kind,names] of Object.entries(scriptNames)) {
|
|
193
214
|
const selected=selectScript(kind,names,scripts),script=selected.script;
|
|
194
215
|
if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
|
|
195
|
-
|
|
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;
|
|
196
222
|
let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
|
|
197
|
-
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;
|
|
198
225
|
if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
|
|
199
226
|
reason:'workspace task and installed-input closure need explicit qualification'};
|
|
200
227
|
const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
|
|
201
228
|
if(recipe==='typescript-direct'&&kind==='typecheck'){
|
|
202
229
|
try{
|
|
203
230
|
if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
|
|
204
|
-
|
|
205
|
-
|
|
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:'.'};});
|
|
206
235
|
classification={level:'ready',qualification:directQualification,
|
|
207
|
-
reason:`explicit local TypeScript ${
|
|
236
|
+
reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
|
|
208
237
|
}catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
|
|
209
238
|
}
|
|
210
239
|
if(recipe==='tests-direct'&&kind==='test'){
|
|
211
240
|
try{
|
|
212
|
-
if(pm.name
|
|
241
|
+
if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
|
|
242
|
+
throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
|
|
213
243
|
const chosen=testRecipeScript(scripts);
|
|
214
244
|
if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
|
|
215
245
|
if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
|
|
@@ -220,20 +250,24 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
220
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`};
|
|
221
251
|
}catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
|
|
222
252
|
}
|
|
223
|
-
if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
classification={level:'ready',reason:'pinned pnpm TypeScript inputs and installation; checked again before reuse',
|
|
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)])]};});
|
|
262
|
+
classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
|
|
234
263
|
qualification:'pnpm-tsc-v1'};}
|
|
235
264
|
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
236
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
|
+
}
|
|
237
271
|
if(classification.qualification)check.qualification=classification.qualification;
|
|
238
272
|
checks.push({config:check,...classification});
|
|
239
273
|
}
|
|
@@ -251,6 +285,16 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
251
285
|
const selected=selectScript(kind,names,member.scripts||{});
|
|
252
286
|
if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
|
|
253
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
|
+
}
|
|
254
298
|
if(!selected.script)continue;
|
|
255
299
|
const command=pm.name==='npm'?['@which:npm','run',selected.script,
|
|
256
300
|
'--workspace',name]:pm.name==='yarn'?
|
|
@@ -271,22 +315,21 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
271
315
|
level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
|
|
272
316
|
}catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
|
|
273
317
|
}
|
|
274
|
-
if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
|
|
275
|
-
|
|
276
|
-
(
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
280
|
-
check.workspace=name;check.qualification='pnpm-tsc-v1';
|
|
281
|
-
check.script=selected.script;
|
|
282
|
-
check.cwd=dir;
|
|
283
|
-
check.command=['@node',`@typescript-bin:${dir}`,
|
|
284
|
-
...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
|
|
285
|
-
check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
|
|
286
|
-
`${dir}/src/**`];
|
|
287
|
-
level='ready';reason='pinned 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;
|
|
288
323
|
}catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
|
|
289
324
|
}
|
|
325
|
+
if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
|
|
326
|
+
try{const project=testProject(root,selected.script,dir);
|
|
327
|
+
Object.assign(check,{workspace:name,script:selected.script,cwd:dir,
|
|
328
|
+
command:['@node',`@test-runner:${project.runner}`,...project.args],...testCheckInputs(project),
|
|
329
|
+
qualification:testsQualification});
|
|
330
|
+
level='ready';reason=`explicit local ${project.runner} ${project.version} run of ${name}'s script ${JSON.stringify(selected.script)}; the whole checkout is its input; the package manager and lifecycle scripts are not executed`;
|
|
331
|
+
}catch(e){reason=`Test recipe unavailable (${e.code||e.message}); original script retained`;}
|
|
332
|
+
}
|
|
290
333
|
checks.push({config:check,level,reason});
|
|
291
334
|
}
|
|
292
335
|
}
|
|
@@ -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&&
|