@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/test-direct.mjs
CHANGED
|
@@ -10,11 +10,13 @@ import path from 'node:path';
|
|
|
10
10
|
import {createHash} from 'node:crypto';
|
|
11
11
|
import {fileURLToPath} from 'node:url';
|
|
12
12
|
import {spawnSync} from 'node:child_process';
|
|
13
|
-
import
|
|
13
|
+
import {patternMatcher} from './glob.mjs';
|
|
14
14
|
import {runFact} from './direct-run-certificate.mjs';
|
|
15
15
|
import {queryAnswer} from './typescript-list.mjs';
|
|
16
|
-
import {pnpmInstallInfo,pnpmProjectInfo} from './pnpm-inputs.mjs';
|
|
16
|
+
import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
|
|
17
17
|
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
18
|
+
import {atomicJson} from './state-store.mjs';
|
|
19
|
+
import {racyUntil} from './path-identity.mjs';
|
|
18
20
|
|
|
19
21
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
20
22
|
export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
|
|
@@ -59,15 +61,17 @@ export function recordedEnvironment(guardFile,env=process.env) {
|
|
|
59
61
|
// input (relative path) at the start checkpoint to its digest; `runner` is the
|
|
60
62
|
// PID of the process REDUE started. Returns {issues, runFacts, environmentReads,
|
|
61
63
|
// isOutput}: environmentReads maps each variable the run read directly to the
|
|
62
|
-
// caller's value digest, or '<unset>'.
|
|
63
|
-
|
|
64
|
+
// caller's value digest, or '<unset>'. `programs` is the general recipe's
|
|
65
|
+
// account of processes (node-script.mjs): `judge(child, parentMain)` says
|
|
66
|
+
// whether a started program is accounted for and must load the recorder,
|
|
67
|
+
// `runner` describes the program REDUE started, and `trusted` names checkout
|
|
68
|
+
// paths (runner caches) whose use is not an input.
|
|
69
|
+
export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=testEnvironment,coverage=null,programs=null}) {
|
|
64
70
|
// The recipe's variables and any the project declares are compared, not guarded.
|
|
65
71
|
const compared=name=>(environment.variables||[]).includes(name)||
|
|
66
72
|
(environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
67
73
|
const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
|
|
68
|
-
const
|
|
69
|
-
const negative=sourcePatterns.filter(p=>p.startsWith('!')).map(p=>micromatch.matcher(p.slice(1),{dot:true}));
|
|
70
|
-
const included=rel=>positive.some(fn=>fn(rel))&&!negative.some(fn=>fn(rel));
|
|
74
|
+
const included=patternMatcher(sourcePatterns);
|
|
71
75
|
const observed=rel=>Object.hasOwn(files||{},rel);
|
|
72
76
|
const headers=new Map(),records=[];
|
|
73
77
|
let names=[];
|
|
@@ -82,16 +86,28 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
82
86
|
headers.set(`${header.pid}-${header.threadId}`,header);
|
|
83
87
|
records.push({header,rows:rows.slice(1)});
|
|
84
88
|
}
|
|
85
|
-
|
|
89
|
+
let opaque=false;
|
|
90
|
+
if(!programs){if(!headers.has(`${runner}-0`))add('the test runner did not load the REDUE recorder');}
|
|
91
|
+
else{const judged=programs.judge(programs.runner,null);
|
|
92
|
+
if(judged.issue)add(judged.issue);
|
|
93
|
+
else if(judged.needsHeader&&!headers.has(`${runner}-0`))add('the package manager did not load the REDUE recorder');
|
|
94
|
+
opaque||=Boolean(judged.opaque);}
|
|
95
|
+
const trusted=programs?.trusted?patternMatcher(['**',...programs.trusted.map(pattern=>'!'+pattern)]):null;
|
|
96
|
+
// A cache directory itself, and everything in it.
|
|
97
|
+
const isTrusted=rel=>trusted!==null&&(!trusted(rel)||!trusted(rel+'/_'));
|
|
86
98
|
// Answers per question; a question answered two ways changed during the run.
|
|
87
99
|
const answers=new Map(),ask=(key,answer)=>{const set=answers.get(key)||new Set();set.add(JSON.stringify(answer));answers.set(key,set);};
|
|
88
|
-
|
|
100
|
+
// Every child with a PID, accepted shells too: a header with one of these
|
|
101
|
+
// PIDs is that child, not an execSync child (which reports no PID).
|
|
102
|
+
const installedWrites=new Set();
|
|
103
|
+
const reads=new Map(),produced=new Set(),created=new Set(),environmentReads={},spawned=new Map(),childPids=new Map();
|
|
89
104
|
for(const {header,rows} of records){
|
|
90
105
|
for(const row of rows){
|
|
91
106
|
const [kind,...value]=Array.isArray(row)?row:[];
|
|
92
107
|
if(kind==='U')add(`recorder: ${value[0]}`);
|
|
93
108
|
else if(kind==='R')reads.set(value[0],reads.get(value[0])||value[1]===true);
|
|
94
109
|
else if(kind==='P')produced.add(path.join(root,value[0]));
|
|
110
|
+
else if(kind==='I')installedWrites.add(value[0]);
|
|
95
111
|
else if(kind==='M')created.add(value[0]);
|
|
96
112
|
else if(kind==='L')ask(`stat\0${value[0]}`,value[1]);
|
|
97
113
|
else if(kind==='D')ask(`entries\0${value[0]}`,value[1]);
|
|
@@ -101,7 +117,15 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
101
117
|
environmentReads[name]=typeof state==='string'?state:'<unset>';}
|
|
102
118
|
else if(kind==='N')add(`network: ${value[0]}`);
|
|
103
119
|
else if(kind==='C'||kind==='S'){
|
|
104
|
-
const
|
|
120
|
+
const [file,args,cwd,searchPath,env,script]=kind==='C'?value.slice(1):value;
|
|
121
|
+
if(kind==='C')childPids.set(header.pid,[...childPids.get(header.pid)||[],value[0]]);
|
|
122
|
+
// The general recipe accounts for each program; one that needs no
|
|
123
|
+
// recorder (a shell, the pnpm executable) is not matched to a header.
|
|
124
|
+
if(programs){const judged=programs.judge({file,args,cwd,searchPath,env,script},header.main??null);
|
|
125
|
+
if(judged.issue){add(judged.issue);continue;}
|
|
126
|
+
opaque||=Boolean(judged.opaque);
|
|
127
|
+
if(!judged.needsHeader)continue;}
|
|
128
|
+
const list=spawned.get(header.pid)||[];list.push({pid:kind==='C'?value[0]:null,file});
|
|
105
129
|
spawned.set(header.pid,list);}
|
|
106
130
|
else if(kind==='T'){if(!headers.has(`${header.pid}-${value[0]}`))add('a worker thread did not load the REDUE recorder');}
|
|
107
131
|
else if(kind!=='H')add('a run record is unreadable');
|
|
@@ -114,9 +138,14 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
114
138
|
const unknown=children.filter(child=>child.pid===null);
|
|
115
139
|
for(const child of children)if(child.pid!==null&&!recorded.some(h=>h.pid===child.pid))
|
|
116
140
|
add(`started ${path.basename(child.file)}, which REDUE does not record`);
|
|
117
|
-
const matched=recorded.filter(h=>!
|
|
141
|
+
const matched=recorded.filter(h=>!(childPids.get(parent)||[]).includes(h.pid)).length;
|
|
118
142
|
if(unknown.length>matched)add(`started ${path.basename(unknown[0].file)}, which REDUE does not record`);
|
|
119
143
|
}
|
|
144
|
+
// What the pnpm executable starts is not seen. Its node-options setting
|
|
145
|
+
// replaces NODE_OPTIONS for everything it starts, so a run through it must
|
|
146
|
+
// show at least one recorded Node process the recorded ones did not start.
|
|
147
|
+
if(opaque&&![...headers.values()].some(h=>h.threadId===0&&h.pid!==runner&&!headers.has(`${h.ppid}-0`)))
|
|
148
|
+
add('no Node process the pnpm executable started loaded the REDUE recorder');
|
|
120
149
|
// Inside a directory the run created. A file the run wrote excuses nothing:
|
|
121
150
|
// another process may have read it before the write. Listings may differ
|
|
122
151
|
// by such files (listingAfter).
|
|
@@ -128,8 +157,11 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
128
157
|
// (coverageReadable); its contents do not decide pass or fail.
|
|
129
158
|
const inCoverage=rel=>coverage!==null&&(rel===coverage||rel.startsWith(coverage+'/'));
|
|
130
159
|
// File content in the checkout must be an observed input.
|
|
160
|
+
// A runner cache is git-ignored; a file in a cache path git keeps is source.
|
|
161
|
+
const trustedReads=[...reads].filter(([rel,found])=>found&&isTrusted(rel)&&!inCreated(checkout(rel))).map(([rel])=>rel);
|
|
162
|
+
const ignoredCaches=gitIgnored(root,trustedReads);
|
|
131
163
|
for(const [rel,found] of [...reads].sort(([a],[b])=>a<b?-1:a>b?1:0)){
|
|
132
|
-
if(inCreated(checkout(rel))||inCoverage(rel))continue;
|
|
164
|
+
if(inCreated(checkout(rel))||inCoverage(rel)||isTrusted(rel)&&(!found||ignoredCaches.has(rel)||produced.has(checkout(rel))))continue;
|
|
133
165
|
if(!included(rel))add(`read ${rel}, which the check's inputs exclude`);
|
|
134
166
|
else if(found&&!observed(rel))add(`read ${rel}, which REDUE does not observe (ignored by git)`);
|
|
135
167
|
// A missing file may be ignored by git, so the index would not see it appear.
|
|
@@ -141,7 +173,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
141
173
|
if(question==='stat'||question==='entries'){
|
|
142
174
|
const full=checkout(where);
|
|
143
175
|
// A path the run made answers differently afterwards; it is output, not input.
|
|
144
|
-
if(madeByRun(full)||produced.has(full)||inCoverage(where))continue;
|
|
176
|
+
if(madeByRun(full)||produced.has(full)||inCoverage(where)||isTrusted(where))continue;
|
|
145
177
|
if(set.size>1){add(`${where} changed during the run`);continue;}
|
|
146
178
|
if(question==='stat'){
|
|
147
179
|
// Observed files and directories: the index covers them. A missing
|
|
@@ -173,7 +205,8 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
173
205
|
produced.has(checkout(rel))&&reads.get(rel)!==true);
|
|
174
206
|
return {issues:issues.length>issueLimit?[...issues.slice(0,issueLimit),`${issues.length-issueLimit} more`]:issues,
|
|
175
207
|
runFacts:runFacts.length>factLimit?[]:[...new Map(runFacts.map(f=>[JSON.stringify(f),f])).values()],
|
|
176
|
-
environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput
|
|
208
|
+
environmentReads:Object.fromEntries(Object.entries(environmentReads).sort(([a],[b])=>a<b?-1:a>b?1:0)),isOutput,opaque,
|
|
209
|
+
wroteInstalled:rel=>installedWrites.has(rel)};
|
|
177
210
|
}
|
|
178
211
|
// The observed files that differ between two checkpoints' file maps.
|
|
179
212
|
export function changedFiles(before,after){
|
|
@@ -336,21 +369,70 @@ export function testWorkspaceDirs(root,pkg){
|
|
|
336
369
|
// Each node_modules the recipe observes as installed: the root's and every
|
|
337
370
|
// workspace package's, relative to the root.
|
|
338
371
|
const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
372
|
+
// pnpm's content-addressed store, as the install recorded it in .modules.yaml:
|
|
373
|
+
// files/<first 2 hex>/<rest of the content's sha512 hex>, "-exec" for
|
|
374
|
+
// executables. pnpm writes new store files and never changes one in place.
|
|
375
|
+
function pnpmStoreFiles(root){
|
|
376
|
+
try{const {storeDir}=yaml(path.join(root,'node_modules','.modules.yaml'));
|
|
377
|
+
return typeof storeDir==='string'&&path.isAbsolute(storeDir)?path.join(storeDir,'files'):null;}
|
|
378
|
+
catch{return null;}
|
|
379
|
+
}
|
|
380
|
+
const identity=st=>`${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
|
|
381
|
+
const sameFile=(file,st)=>{
|
|
382
|
+
try{const other=fs.statSync(file,{bigint:true});return other.dev===st.dev&&other.ino===st.ino;}
|
|
383
|
+
catch{return false;}};
|
|
384
|
+
// The file's store path relative to `files`, or null when the store does not
|
|
385
|
+
// hold this file at its content's address.
|
|
386
|
+
function storePath(files,file,st){
|
|
387
|
+
const hash=createHash('sha512'),buffer=Buffer.alloc(1<<20),fd=fs.openSync(file,'r');
|
|
388
|
+
try{for(let n;(n=fs.readSync(fd,buffer,0,buffer.length,null))>0;)hash.update(buffer.subarray(0,n));}
|
|
389
|
+
finally{fs.closeSync(fd);}
|
|
390
|
+
const hex=hash.digest('hex'),at=path.join(hex.slice(0,2),hex.slice(2));
|
|
391
|
+
return [at,at+'-exec'].find(candidate=>sameFile(path.join(files,candidate),st))??null;
|
|
392
|
+
}
|
|
393
|
+
// The first installed file with more than one link, as {file, reason}, or
|
|
394
|
+
// null. A package store outside the checkout (pnpm's default on Linux, Yarn's
|
|
395
|
+
// hardlinks modes) can change such a file with no event here. A link into
|
|
396
|
+
// pnpm's own store is accepted: the store's file is named by its content.
|
|
397
|
+
// Checked at every recorded run: a later install that adds links replaces the
|
|
398
|
+
// files, which is a change REDUE sees. `cache` (in owned state) keeps each
|
|
399
|
+
// file found in the store with its identity and store path, so an unchanged
|
|
400
|
+
// file is not hashed again; its store link is still checked.
|
|
401
|
+
export function sharedInstalledFile(root,project={},cache=null){
|
|
344
402
|
const stack=modulesDirs(project).map(dir=>path.join(root,dir));
|
|
403
|
+
const files=pnpmStoreFiles(root),now=Date.now();
|
|
404
|
+
let known={},changed=false;
|
|
405
|
+
if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
|
|
406
|
+
if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
|
|
407
|
+
const found={};
|
|
408
|
+
const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
|
|
345
409
|
while(stack.length){
|
|
346
410
|
const dir=stack.pop();let entries;
|
|
347
411
|
try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
|
|
348
412
|
for(const entry of entries){
|
|
349
413
|
const file=path.join(dir,entry.name);
|
|
350
|
-
if(entry.isDirectory())stack.push(file);
|
|
351
|
-
|
|
414
|
+
if(entry.isDirectory()){stack.push(file);continue;}
|
|
415
|
+
if(!entry.isFile())continue;
|
|
416
|
+
let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
|
|
417
|
+
if(st.nlink<=1n)continue;
|
|
418
|
+
if(!files)return shared(file,'has another hard link, and the install records no pnpm store');
|
|
419
|
+
const key=`${st.dev}:${st.ino}`,cached=known[key];
|
|
420
|
+
let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
|
|
421
|
+
sameFile(path.join(files,cached[1]),st)?cached[1]:null;
|
|
422
|
+
if(!at){
|
|
423
|
+
try{at=storePath(files,file,st);}
|
|
424
|
+
catch(e){return shared(file,`could not be checked against pnpm's store (${e.code||e.message})`);}
|
|
425
|
+
if(!at)return shared(file,"has another hard link outside pnpm's store");
|
|
426
|
+
changed=true;
|
|
427
|
+
}
|
|
428
|
+
// A file changed within the racy window could change again unseen in
|
|
429
|
+
// the same timestamp tick; it is hashed again next time.
|
|
430
|
+
if(racyUntil(st)<=now)found[key]=[identity(st),at];
|
|
431
|
+
else changed=true;
|
|
352
432
|
}
|
|
353
433
|
}
|
|
434
|
+
if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
|
|
435
|
+
try{atomicJson(cache,{files,entries:found});}catch{}
|
|
354
436
|
return null;
|
|
355
437
|
}
|
|
356
438
|
// The runner a package's script reaches: Node's lookup from the package
|
package/src/test-recorder.cjs
CHANGED
|
@@ -23,11 +23,15 @@ function install(){
|
|
|
23
23
|
const emit=row=>{try{const line=Buffer.from(JSON.stringify(row)+'\n');
|
|
24
24
|
for(let at=0;at<line.length;)at+=original.writeSync(fd,line,at,line.length-at);}catch{}};
|
|
25
25
|
const failed=reason=>emit(['U',reason]);
|
|
26
|
-
|
|
26
|
+
// The main script tells the guard which program a process runs (a package
|
|
27
|
+
// manager may start helpers of its own).
|
|
28
|
+
emit(['H',{pid:process.pid,ppid:process.ppid,threadId,main:typeof process.argv[1]==='string'?process.argv[1]:null}]);
|
|
27
29
|
// Every node_modules observed as installed: the root's and each workspace package's.
|
|
28
30
|
const sep=path.sep,root=config.root,installed=Array.isArray(config.installed)?config.installed:[path.join(root,'node_modules')],
|
|
29
31
|
tmp=config.tmp;
|
|
30
32
|
const within=(file,dir)=>file===dir||file.startsWith(dir+sep);
|
|
33
|
+
let installedReal=null;
|
|
34
|
+
const realInstalled=()=>installedReal??=installed.map(dir=>realDir(dir));
|
|
31
35
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
32
36
|
// Directory realpaths, so a checkout path spelled through a link is still
|
|
33
37
|
// classified as the checkout.
|
|
@@ -44,7 +48,8 @@ function install(){
|
|
|
44
48
|
// The absolute path an argument names; undefined for a descriptor, whose
|
|
45
49
|
// open was recorded; null for anything else.
|
|
46
50
|
function located(file){
|
|
47
|
-
|
|
51
|
+
// A descriptor or a FileHandle: its open was recorded.
|
|
52
|
+
if(typeof file==='number'||file&&typeof file==='object'&&typeof file.fd==='number'&&typeof file.close==='function')return undefined;
|
|
48
53
|
if(Buffer.isBuffer(file))file=file.toString();
|
|
49
54
|
else if(file instanceof URL){if(file.protocol!=='file:')return null;file=require('node:url').fileURLToPath(file);}
|
|
50
55
|
if(typeof file!=='string')return null;
|
|
@@ -75,18 +80,45 @@ function install(){
|
|
|
75
80
|
if(!produced.size)return false;
|
|
76
81
|
for(let at=full;;at=path.dirname(at)){if(produced.has(at))return true;if(path.dirname(at)===at)return false;}
|
|
77
82
|
}
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
// Runner data and the toolchain's own files (a package manager's install
|
|
84
|
+
// and logs) are not inputs.
|
|
85
|
+
const runnerData=[...(config.runnerData||[]),...(Array.isArray(config.toolchain)?config.toolchain:[])];
|
|
86
|
+
// Runner caches in the temporary directory, keyed by content: Jest's,
|
|
87
|
+
// Yarn 1's compile cache, tsx's, jiti's, ts-node's and Babel's.
|
|
88
|
+
const tmpCache=/^(?:jest_|v8-compile-cache|tsx-\d+$|jiti$|node-jiti$|ts-node|babel)/;
|
|
89
|
+
const runnerCache=full=>tmp.some(dir=>within(full,dir)&&tmpCache.test(full.slice(dir.length+1).split(sep)[0]))||
|
|
80
90
|
runnerData.some(dir=>within(full,dir));
|
|
91
|
+
// A read of the process's own executable through /proc (detect-libc reads
|
|
92
|
+
// its ELF header there), when the process runs the Node on PATH, which the
|
|
93
|
+
// environment identifies by its file.
|
|
94
|
+
const ownExePath=process.platform==='linux'?`/proc/${process.pid}/exe`:null;
|
|
95
|
+
let ownExe;
|
|
96
|
+
const ownExecutable=(role,full)=>{
|
|
97
|
+
if(role!=='read'||full!==ownExePath)return false;
|
|
98
|
+
if(ownExe===undefined)try{ownExe=typeof config.node==='string'&&original.real(full)===config.node;}catch{ownExe=false;}
|
|
99
|
+
return ownExe;
|
|
100
|
+
};
|
|
81
101
|
// One filesystem access by its role: read (content), produce, touch (a write
|
|
82
102
|
// that keeps earlier content), mkdir, mkdtemp, list, stat or realpath.
|
|
83
103
|
function access(role,file,error=null,result=undefined,recursive=false){
|
|
84
104
|
try{
|
|
85
|
-
|
|
105
|
+
let full=located(file);
|
|
86
106
|
if(full===undefined)return;
|
|
87
107
|
if(full===null){if(!['produce','touch','mkdir'].includes(role))failed(`${role} of an unsupported path argument`);return;}
|
|
88
|
-
// Installed files are observed by identity.
|
|
89
|
-
|
|
108
|
+
// Installed files are observed by identity. A path under node_modules
|
|
109
|
+
// that a link leads out of the installed tree (a workspace package's
|
|
110
|
+
// link) is classified where it leads.
|
|
111
|
+
if(installed.some(dir=>within(full,dir))){
|
|
112
|
+
// A listing, lookup or realpath of the link itself follows it.
|
|
113
|
+
const real=['list','stat','realpath'].includes(role)?realDir(full):path.join(realDir(path.dirname(full)),path.basename(full));
|
|
114
|
+
// A write there is noted apart from checkout outputs: the guard does
|
|
115
|
+
// not see reads of installed files, so only named tool files use it.
|
|
116
|
+
if(real===full||realInstalled().some(dir=>within(real,dir))){
|
|
117
|
+
if(role==='produce'&&!error&&within(full,root))once(['I',rel(full)]);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
full=real;
|
|
121
|
+
}
|
|
90
122
|
if(role==='produce'){if(!error)produced.add(full);if(!error&&within(full,root))once(['P',rel(full)]);return;}
|
|
91
123
|
// A directory the run created held nothing before it, so everything in
|
|
92
124
|
// it is the run's output, whichever process reads it.
|
|
@@ -105,7 +137,7 @@ function install(){
|
|
|
105
137
|
else if(role==='list')emit(['D',rel(full),error?missing(error):entries(full)]);
|
|
106
138
|
return;
|
|
107
139
|
}
|
|
108
|
-
if(runnerCache(full))return;
|
|
140
|
+
if(runnerCache(full)||ownExecutable(role,full))return;
|
|
109
141
|
// A small file read outside the checkout is kept by its content, as
|
|
110
142
|
// queryAnswer's readFile hashes it, so status can ask again.
|
|
111
143
|
if(role==='read'&&!error){
|
|
@@ -212,7 +244,8 @@ function install(){
|
|
|
212
244
|
envSeen.add(key);emit(['E',key,caller[key]]);
|
|
213
245
|
}catch{failed('recorder failure');}
|
|
214
246
|
}
|
|
215
|
-
const
|
|
247
|
+
const targets=new WeakMap();
|
|
248
|
+
const recordingEnv=source=>{const proxy=new Proxy(source,{
|
|
216
249
|
ownKeys(t){const keys=Reflect.ownKeys(t),current={keys:new Set(keys),last:null,copy:nodeInternal()};enumeration=current;
|
|
217
250
|
queueMicrotask(()=>{if(enumeration===current)enumeration=null;});return keys;},
|
|
218
251
|
getOwnPropertyDescriptor(t,key){
|
|
@@ -225,7 +258,10 @@ function install(){
|
|
|
225
258
|
return Reflect.get(t,key);},
|
|
226
259
|
has(t,key){if(enumeration)enumeration.last=null;envRead(t,key);return Reflect.has(t,key);},
|
|
227
260
|
set(t,key,value){t[key]=value;return true;},
|
|
228
|
-
});
|
|
261
|
+
});targets.set(proxy,source);return proxy;};
|
|
262
|
+
// PATH as a child receives it, read without recording a read.
|
|
263
|
+
const pathOf=env=>{try{const value=Reflect.get(targets.get(env)??env??{},'PATH');return typeof value==='string'?value:null;}
|
|
264
|
+
catch{return null;}};
|
|
229
265
|
function wrapEnv(holder){
|
|
230
266
|
let current=recordingEnv(holder.env);
|
|
231
267
|
// Code that replaces the environment object keeps being recorded.
|
|
@@ -289,17 +325,63 @@ function install(){
|
|
|
289
325
|
}
|
|
290
326
|
// Child processes and threads must load this recorder too; the guard checks
|
|
291
327
|
// for their headers. Children of execSync/execFileSync report no PID, so
|
|
292
|
-
// they are counted instead.
|
|
328
|
+
// they are counted instead. Each row names the program and its arguments
|
|
329
|
+
// as Node's spawn normalizes them (a `shell` option runs /bin/sh -c and the
|
|
330
|
+
// joined command), the working directory and PATH, so the guard can tell
|
|
331
|
+
// which program a name ran and read what a shell was asked to run.
|
|
332
|
+
// Arguments over 64 KB in all are left out (null).
|
|
333
|
+
const argsOf=args=>{try{const list=Array.isArray(args)?args.map(String):[];
|
|
334
|
+
return list.reduce((n,arg)=>n+arg.length,0)>65536?null:list;}catch{return null;}};
|
|
335
|
+
const normalized=(file,args,options,shell)=>{
|
|
336
|
+
if(!Array.isArray(args)){options=args;args=[];}
|
|
337
|
+
shell=options?.shell??shell;
|
|
338
|
+
if(!shell)return [String(file),argsOf(args)];
|
|
339
|
+
return [typeof shell==='string'?shell:'/bin/sh',['-c',[file,...args].join(' ')]];
|
|
340
|
+
};
|
|
341
|
+
// What the child's environment says: whether NODE_OPTIONS still loads this
|
|
342
|
+
// recorder for the same run (npm's and pnpm's `node-options` setting
|
|
343
|
+
// replaces it), and whether BASH_ENV is set (bash -c sources it).
|
|
344
|
+
const envFacts=lookupVar=>{
|
|
345
|
+
try{const options=lookupVar('NODE_OPTIONS');
|
|
346
|
+
return {preload:typeof options==='string'&&options.includes(__filename)&&lookupVar('REDUE_RUN_RECORD')===configFile,
|
|
347
|
+
bashEnv:Boolean(lookupVar('BASH_ENV'))};}
|
|
348
|
+
catch{return {preload:false,bashEnv:true};}
|
|
349
|
+
};
|
|
350
|
+
const envOf=env=>envFacts(name=>{const value=Reflect.get(targets.get(env)??env??{},name);return typeof value==='string'?value:undefined;});
|
|
351
|
+
const pairsOf=pairs=>envFacts(name=>{const pair=(pairs||[]).find(item=>typeof item==='string'&&item.startsWith(name+'='));
|
|
352
|
+
return pair?pair.slice(name.length+1):undefined;});
|
|
353
|
+
const where=options=>[path.resolve(typeof options?.cwd==='string'?options.cwd:options?.cwd instanceof URL?
|
|
354
|
+
require('node:url').fileURLToPath(options.cwd):'.'),pathOf(options?.env??process.env),envOf(options?.env??process.env)];
|
|
355
|
+
const optionsOf=(args,options)=>Array.isArray(args)?options:args;
|
|
356
|
+
// A #! script the child runs, as it is now: Yarn writes its bin wrappers
|
|
357
|
+
// into a temporary directory and removes them after the run.
|
|
358
|
+
function scriptOf(file,cwd,searchPath){
|
|
359
|
+
try{
|
|
360
|
+
const candidates=file.includes(sep)?[path.resolve(cwd,file)]:String(searchPath??'').split(path.delimiter).map(dir=>path.join(path.resolve(cwd,dir||'.'),file));
|
|
361
|
+
for(const candidate of candidates){
|
|
362
|
+
let st;try{st=original.statSync(candidate);}catch{continue;}
|
|
363
|
+
if(!st.isFile()||!(st.mode&0o111))continue;
|
|
364
|
+
if(st.size>16384)return null;
|
|
365
|
+
const text=original.readFileSync(candidate,'utf8');
|
|
366
|
+
return text.startsWith('#!')?{file:original.real(candidate),text}:null;
|
|
367
|
+
}
|
|
368
|
+
}catch{}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
293
371
|
const childProcess=require('node:child_process'),spawn=childProcess.ChildProcess.prototype.spawn;
|
|
294
372
|
childProcess.ChildProcess.prototype.spawn=function(options){
|
|
295
373
|
const result=spawn.call(this,options);
|
|
296
|
-
try{if(this.pid)
|
|
374
|
+
try{if(this.pid){const pair=(options?.envPairs||[]).find(item=>typeof item==='string'&&item.startsWith('PATH='));
|
|
375
|
+
emit(['C',this.pid,String(options?.file),argsOf(options?.args?.slice(1)),where(options)[0],pair?pair.slice(5):null,
|
|
376
|
+
pairsOf(options?.envPairs),scriptOf(String(options?.file),where(options)[0],pair?pair.slice(5):null)]);}}
|
|
377
|
+
catch{failed('recorder failure');}
|
|
297
378
|
return result;
|
|
298
379
|
};
|
|
299
380
|
const spawnSync=childProcess.spawnSync;
|
|
300
381
|
childProcess.spawnSync=function(file,...rest){
|
|
301
382
|
const result=spawnSync.call(this,file,...rest);
|
|
302
|
-
try{if(result?.pid)emit(['C',result.pid
|
|
383
|
+
try{if(result?.pid)emit(['C',result.pid,...normalized(file,rest[0],rest[1]),...where(optionsOf(rest[0],rest[1]))]);}
|
|
384
|
+
catch{failed('recorder failure');}
|
|
303
385
|
return result;
|
|
304
386
|
};
|
|
305
387
|
for(const name of ['execSync','execFileSync']){
|
|
@@ -308,12 +390,17 @@ function install(){
|
|
|
308
390
|
let started=true;
|
|
309
391
|
try{return call.call(this,file,...rest);}
|
|
310
392
|
catch(error){if(error?.code==='ENOENT'&&!error.pid)started=false;throw error;}
|
|
311
|
-
finally{if(started)emit(['S',
|
|
393
|
+
finally{if(started)try{emit(['S',...name==='execSync'?normalized(file,[],rest[0],true):normalized(file,rest[0],rest[1]),
|
|
394
|
+
...where(name==='execSync'?rest[0]:optionsOf(rest[0],rest[1]))]);}
|
|
395
|
+
catch{failed('recorder failure');}}
|
|
312
396
|
};
|
|
313
397
|
}
|
|
314
398
|
const workers=require('node:worker_threads'),Worker=workers.Worker;
|
|
315
399
|
workers.Worker=class extends Worker{
|
|
316
400
|
constructor(...args){super(...args);try{emit(['T',this.threadId]);}catch{failed('recorder failure');}}
|
|
317
401
|
};
|
|
402
|
+
// Node reads an environment file natively; this is the one JavaScript entry to it.
|
|
403
|
+
if(typeof process.loadEnvFile==='function'){const load=process.loadEnvFile;
|
|
404
|
+
process.loadEnvFile=function(file='.env',...rest){access('read',file);return load.call(this,file,...rest);};}
|
|
318
405
|
try{Module.syncBuiltinESMExports();}catch{failed('recorder failure');}
|
|
319
406
|
}
|
|
@@ -9,7 +9,7 @@ import {timing} from './decision-profile.mjs';
|
|
|
9
9
|
const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
|
|
10
10
|
const digestFile=file=>hash(fs.readFileSync(file));
|
|
11
11
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
12
|
-
export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
12
|
+
export function typeScriptInputFacts(root,tsRoot,tsc,config='tsconfig.json'){
|
|
13
13
|
const configReads=new Set(),visited=new Set(),referenced=[];
|
|
14
14
|
let buildInfo=null;
|
|
15
15
|
let ts;try{ts=require(tsRoot);}catch{}
|
|
@@ -37,15 +37,15 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
37
37
|
parseConfig(reference.path.endsWith('.json')?reference.path:
|
|
38
38
|
path.join(reference.path,'tsconfig.json'));
|
|
39
39
|
};
|
|
40
|
-
parseConfig(path.join(root,
|
|
40
|
+
parseConfig(path.join(root,config),true);
|
|
41
41
|
}else{
|
|
42
42
|
// TypeScript 7's native compiler does not expose the previous config API.
|
|
43
43
|
// Its CLI reports the effective config; restrict this path to one direct
|
|
44
44
|
// config rather than guessing about extends/reference provenance.
|
|
45
|
-
const
|
|
45
|
+
const file=path.join(root,config),raw=fs.readFileSync(file,'utf8');
|
|
46
46
|
if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
|
|
47
47
|
fail('typescript-config-graph-unsupported');
|
|
48
|
-
const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
|
|
48
|
+
const shown=spawnSync(process.execPath,[tsc,'--showConfig','-p',config],{cwd:root,
|
|
49
49
|
env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
|
|
50
50
|
stdio:['ignore','pipe','pipe']});
|
|
51
51
|
if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
|
|
@@ -53,13 +53,13 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
53
53
|
if(effective.compilerOptions?.plugins?.length||
|
|
54
54
|
effective.compilerOptions?.incremental||effective.compilerOptions?.composite)
|
|
55
55
|
fail('typescript-config-graph-unsupported');
|
|
56
|
-
configReads.add(
|
|
56
|
+
configReads.add(file);
|
|
57
57
|
}
|
|
58
58
|
for(const file of configReads)if(!within(realObservedPath(file),root))
|
|
59
59
|
fail('typescript-config-outside-repository');
|
|
60
60
|
const listingStarted=performance.now();
|
|
61
|
-
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
|
|
62
|
-
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly'],
|
|
61
|
+
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root,config):null;
|
|
62
|
+
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly','-p',config],
|
|
63
63
|
{cwd:root,env:process.env,encoding:'utf8',timeout:probeTimeLeft(process.platform==='win32'?10000:4000),
|
|
64
64
|
maxBuffer:16*1024*1024,
|
|
65
65
|
stdio:['ignore','pipe','pipe']});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The package-script forms of a standalone TypeScript check: one `tsc` run, or
|
|
2
|
+
// several joined by `&&`, each with these arguments in any order:
|
|
3
|
+
// --noEmit, -p/--project PATH, --skipLibCheck, --pretty [true|false].
|
|
4
|
+
// Anything else (tsc -b, --watch, other options, other commands) is not one.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
// No shell metacharacters; a leading @ would read as a REDUE token.
|
|
9
|
+
const word=/^[A-Za-z0-9_./+-][A-Za-z0-9_./@+-]*$/;
|
|
10
|
+
// More runs than this is not a script REDUE splits into checks.
|
|
11
|
+
const maxRuns=16;
|
|
12
|
+
function parseRun(words){
|
|
13
|
+
if(words[0]!=='tsc'||!words.every(value=>word.test(value)))return null;
|
|
14
|
+
let project=null,noEmit=false;
|
|
15
|
+
for(let i=1;i<words.length;i++){
|
|
16
|
+
const value=words[i];
|
|
17
|
+
if(value==='-p'||value==='--project'){
|
|
18
|
+
if(project!==null||!words[i+1]||words[i+1].startsWith('-'))return null;
|
|
19
|
+
project=words[++i];
|
|
20
|
+
}else if(value==='--noEmit')noEmit=true;
|
|
21
|
+
else if(value==='--pretty'){if(['true','false'].includes(words[i+1]))i++;}
|
|
22
|
+
else if(value!=='--skipLibCheck')return null;
|
|
23
|
+
}
|
|
24
|
+
return {args:words.slice(1),project,noEmit};
|
|
25
|
+
}
|
|
26
|
+
// The runs of a script, as [{args, project, noEmit}], or null.
|
|
27
|
+
export function tscRuns(text){
|
|
28
|
+
if(typeof text!=='string'||!text.trim())return null;
|
|
29
|
+
const parts=text.trim().split(/[ \t]+&&[ \t]+/);
|
|
30
|
+
if(parts.length>maxRuns)return null;
|
|
31
|
+
const runs=parts.map(part=>parseRun(part.split(/[ \t]+/)));
|
|
32
|
+
return runs.every(Boolean)?runs:null;
|
|
33
|
+
}
|
|
34
|
+
// The run a check makes: its `script` (null for a check init proposed without
|
|
35
|
+
// one), the tsc arguments after the compiler in its command, and `count`, the
|
|
36
|
+
// number of configured checks for that script (more than one for a chain).
|
|
37
|
+
// Returns {run} with the parsed run, or {issue} when the arguments are outside
|
|
38
|
+
// the forms, or the script no longer has exactly `count` runs, one of them
|
|
39
|
+
// with exactly these arguments.
|
|
40
|
+
export function tscRun(scripts,{script=null,args,count=1}){
|
|
41
|
+
if(!Array.isArray(args)||!args.every(value=>typeof value==='string'))return {issue:'TypeScript arguments are invalid'};
|
|
42
|
+
const own=parseRun(['tsc',...args]);
|
|
43
|
+
if(!own)return {issue:'TypeScript arguments are outside the supported forms'};
|
|
44
|
+
if(script===null)return {run:own};
|
|
45
|
+
if(typeof scripts?.[script]!=='string')return {issue:'typecheck script was removed'};
|
|
46
|
+
if(scripts['pre'+script]||scripts['post'+script])return {issue:'typecheck script has pre or post scripts'};
|
|
47
|
+
const runs=tscRuns(scripts[script]);
|
|
48
|
+
if(runs?.length!==count||!runs.some(run=>JSON.stringify(run.args)===JSON.stringify(args)))
|
|
49
|
+
return {issue:'typecheck script changed'};
|
|
50
|
+
return {run:own};
|
|
51
|
+
}
|
|
52
|
+
// The config file a run names, relative to the directory `dir` it runs in
|
|
53
|
+
// (posix): tsconfig.json without -p, a directory's tsconfig.json, or null
|
|
54
|
+
// when it leaves that directory or the arguments are not a run.
|
|
55
|
+
export function runConfig(dir,args){
|
|
56
|
+
const run=parseRun(['tsc',...args]);
|
|
57
|
+
if(!run)return null;
|
|
58
|
+
let file=path.posix.normalize((run.project??'tsconfig.json').split(path.sep).join('/'));
|
|
59
|
+
let directory=false;try{directory=fs.statSync(path.join(dir,file)).isDirectory();}catch{}
|
|
60
|
+
if(!file.endsWith('.json')||directory)file=path.posix.join(file,'tsconfig.json');
|
|
61
|
+
if(path.posix.isAbsolute(file)||file==='..'||file.startsWith('../'))return null;
|
|
62
|
+
return file;
|
|
63
|
+
}
|
|
64
|
+
// The check names for a script's runs: the kind's name for one run, and
|
|
65
|
+
// NAME.1 ... NAME.n for a chain (not #: the hook refuses it and zsh may glob it).
|
|
66
|
+
export const partName=(name,index,count)=>count===1?name:`${name}.${index}`;
|
|
67
|
+
// Probe arguments for a run, and back: SCRIPT (or -), COUNT, then the tsc
|
|
68
|
+
// arguments.
|
|
69
|
+
export const runArgv=({script=null,args,count=1})=>[script??'-',String(count),...args];
|
|
70
|
+
export function runFromArgv(values){
|
|
71
|
+
const [script,count,...args]=values;
|
|
72
|
+
if(typeof script!=='string'||!script||!/^[1-9]\d?$/.test(count||''))throw Object.assign(Error('probe-arguments'),{code:'probe-arguments'});
|
|
73
|
+
return {script:script==='-'?null:script,args,count:Number(count)};
|
|
74
|
+
}
|
|
75
|
+
// The run a configured check makes: its script, the arguments after the
|
|
76
|
+
// compiler in its command, and how many checks the config has for the script
|
|
77
|
+
// (`runCount`, set when the config is loaded).
|
|
78
|
+
export const checkRun=check=>({script:check.script??null,
|
|
79
|
+
args:Array.isArray(check.command)?check.command.slice(2):[],count:check.runCount??1});
|
|
80
|
+
// A script's checks in the order of its runs: one check per run with the same
|
|
81
|
+
// arguments ({name, args} each), as names; null when they do not correspond.
|
|
82
|
+
export function scriptRunChecks(text,checks){
|
|
83
|
+
const runs=tscRuns(text);
|
|
84
|
+
if(!runs||runs.length!==checks.length)return null;
|
|
85
|
+
const left=[...checks],names=[];
|
|
86
|
+
for(const run of runs){
|
|
87
|
+
const at=left.findIndex(check=>JSON.stringify(check.args)===JSON.stringify(run.args));
|
|
88
|
+
if(at<0)return null;
|
|
89
|
+
names.push(left.splice(at,1)[0].name);
|
|
90
|
+
}
|
|
91
|
+
return names;
|
|
92
|
+
}
|
package/src/typescript-list.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function queriesMatch(queries) {
|
|
|
63
63
|
return queries.every(([kind,file,answer,options])=>
|
|
64
64
|
JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
|
|
65
65
|
}
|
|
66
|
-
export function compilerFiles(tsRoot,cwd) {
|
|
66
|
+
export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
|
|
67
67
|
const require=createRequire(import.meta.url),ts=require(tsRoot);
|
|
68
68
|
if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
|
|
69
69
|
const queries=new Map();
|
|
@@ -114,7 +114,7 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
114
114
|
};
|
|
115
115
|
const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
|
|
116
116
|
// `extends` of a package config resolves through realpath too.
|
|
117
|
-
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,
|
|
117
|
+
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,config),{},
|
|
118
118
|
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
|
|
119
119
|
onUnRecoverableConfigFileDiagnostic(){}});
|
|
120
120
|
if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
|
|
@@ -2,6 +2,7 @@ import {directProject,directQualification,directEnvironment} from './direct-type
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {realObservedPath} from './path-identity.mjs';
|
|
5
|
+
import {checkRun,runArgv} from './typescript-invocation.mjs';
|
|
5
6
|
|
|
6
7
|
// This is a machine-enforced contract, not a claim inferred from a script name.
|
|
7
8
|
// Runtime validation checks effective compiler membership, configuration and
|
|
@@ -11,10 +12,9 @@ export function qualifyTypeScript(root,selected,discovered={}) {
|
|
|
11
12
|
if(selected.qualification===directQualification){
|
|
12
13
|
const issues=[];
|
|
13
14
|
try{
|
|
14
|
-
const compiler=directProject(root,selected
|
|
15
|
-
if(selected.command?.
|
|
16
|
-
selected.command[1]!==compiler.entry||selected.
|
|
17
|
-
selected.cwd&&selected.cwd!=='.')issues.push('direct compiler invocation changed');
|
|
15
|
+
const compiler=directProject(root,checkRun(selected));
|
|
16
|
+
if(selected.command?.[0]!==process.execPath||
|
|
17
|
+
selected.command[1]!==compiler.entry||selected.cwd&&selected.cwd!=='.')issues.push('direct compiler invocation changed');
|
|
18
18
|
}catch(e){issues.push(e.code||'direct compiler prerequisites unavailable');}
|
|
19
19
|
if(!selected.inputs?.includes('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}')||
|
|
20
20
|
!selected.inputs?.includes('package.json')||!selected.inputs?.includes('tsconfig.json')||
|
|
@@ -24,23 +24,22 @@ export function qualifyTypeScript(root,selected,discovered={}) {
|
|
|
24
24
|
!directEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
|
|
25
25
|
issues.push('direct compiler execution context incomplete');
|
|
26
26
|
if(selected.probes?.length!==1||!selected.probes?.some(argv=>path.basename(argv[1]||'')==='direct-typescript-probe.mjs'&&
|
|
27
|
-
argv[0]===process.execPath&&argv[2]===root&&
|
|
27
|
+
argv[0]===process.execPath&&argv[2]===root&&
|
|
28
|
+
JSON.stringify(argv.slice(3))===JSON.stringify(runArgv(checkRun(selected)))))issues.push('direct compiler probe missing');
|
|
28
29
|
return {qualified:issues.length===0,issues};
|
|
29
30
|
}
|
|
30
31
|
if(selected.qualification==='pnpm-tsc-v1'){
|
|
31
32
|
const issues=[];
|
|
32
33
|
const workspace=selected.workspace||'.';
|
|
33
|
-
const script=selected.script||selected.command?.at(-1);
|
|
34
34
|
try{
|
|
35
35
|
if(discovered.issue)throw Error(discovered.issue);
|
|
36
36
|
const contract=discovered.workspace;
|
|
37
37
|
if(!contract)throw Error('pnpm TypeScript input discovery unavailable');
|
|
38
38
|
issues.push(...contract.limitations);
|
|
39
|
-
|
|
39
|
+
// The arguments are checked against the script by the contract itself.
|
|
40
40
|
if(path.resolve(root,selected.cwd||'.')!==path.join(root,contract.workspace.dir)||
|
|
41
41
|
selected.command?.[0]!==process.execPath||
|
|
42
|
-
selected.command?.[1]!==contract.tsc
|
|
43
|
-
JSON.stringify(selected.command.slice(2))!==JSON.stringify(declared))
|
|
42
|
+
selected.command?.[1]!==contract.tsc)
|
|
44
43
|
issues.push('standalone TypeScript invocation changed');
|
|
45
44
|
}catch(e){issues.push(e.message);}
|
|
46
45
|
if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='pnpm-typecheck-probe.mjs'))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import {spawnSync} from 'node:child_process';
|
|
6
|
-
import
|
|
6
|
+
import {patternMatcher} from './glob.mjs';
|
|
7
7
|
|
|
8
8
|
export function workspaceManifests(root,patterns) {
|
|
9
9
|
const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
@@ -12,11 +12,10 @@ export function workspaceManifests(root,patterns) {
|
|
|
12
12
|
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
|
|
13
13
|
if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
|
|
14
14
|
// `!pattern` entries exclude, as in pnpm-workspace.yaml.
|
|
15
|
-
const
|
|
15
|
+
const included=patternMatcher(patterns);
|
|
16
16
|
const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
|
|
17
17
|
const dir=path.posix.dirname(file);
|
|
18
|
-
return dir!=='.'&&!dir.split('/').includes('node_modules')&&
|
|
19
|
-
micromatch.isMatch(dir,positive,{dot:true})&&!(negative.length&µmatch.isMatch(dir,negative,{dot:true}));
|
|
18
|
+
return dir!=='.'&&!dir.split('/').includes('node_modules')&&included(dir);
|
|
20
19
|
}).sort();
|
|
21
20
|
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
22
21
|
return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));
|