@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.
@@ -7,16 +7,18 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {createRequire} from 'node:module';
9
9
  import {execFileSync,spawnSync} from 'node:child_process';
10
- import micromatch from './glob.mjs';
10
+ import micromatch,{patternMatcher} from './glob.mjs';
11
11
  import YAML from 'yaml';
12
12
  import {resolveLinks} from './installed-inputs.mjs';
13
13
  import {withinPath as within,realObservedPath,samePath} from './path-identity.mjs';
14
14
  import {timing} from './decision-profile.mjs';
15
+ import {npmrc} from './npmrc.mjs';
16
+ import {runConfig,tscRun,tscRuns} from './typescript-invocation.mjs';
15
17
 
16
18
  const rel=(root,file)=>path.relative(root,file).split(path.sep).join('/');
17
19
  const read=file=>JSON.parse(fs.readFileSync(file,'utf8'));
18
20
  const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
19
- function yaml(file){
21
+ export function yaml(file){
20
22
  const doc=YAML.parseDocument(fs.readFileSync(file,'utf8'),{uniqueKeys:true});
21
23
  if(doc.errors.length)throw Error(`${path.basename(file)} cannot be interpreted safely`);
22
24
  return doc.toJS()||{};
@@ -41,20 +43,6 @@ const refused=[['modulesDir','modules-dir'],['virtualStoreDir','virtual-store-di
41
43
  ['enableGlobalVirtualStore','enable-global-virtual-store'],['injectWorkspacePackages','inject-workspace-packages'],
42
44
  ['sharedWorkspaceLockfile','shared-workspace-lockfile'],['pnpmfile','pnpmfile'],['globalPnpmfile','global-pnpmfile'],
43
45
  ['configDependencies','config-dependencies']];
44
- // .npmrc read as pnpm's ini parser reads it: a BOM is dropped, a bare key is
45
- // true, quotes are removed, and "true"/"false" become booleans.
46
- function npmrc(file){
47
- const settings={};
48
- if(!exists(file))return settings;
49
- for(const line of fs.readFileSync(file,'utf8').replace(/^\uFEFF/,'').split(/\r?\n/)){
50
- const trimmed=line.trim();if(!trimmed||/^[#;]/.test(trimmed))continue;
51
- const at=trimmed.indexOf('='),key=(at<0?trimmed:trimmed.slice(0,at)).trim();
52
- let value=at<0?true:trimmed.slice(at+1).trim();
53
- if(typeof value==='string'&&/^(["']).*\1$/.test(value))value=value.slice(1,-1);
54
- settings[key]=value==='true'?true:value==='false'?false:value;
55
- }
56
- return settings;
57
- }
58
46
  // Whether a refused setting is set: present at all, except that only
59
47
  // sharedWorkspaceLockfile=false and injectWorkspacePackages=true move files.
60
48
  const setting=(key,value)=>value!==undefined&&
@@ -204,16 +192,37 @@ function packageAt(root,requester,name){
204
192
  }
205
193
  return {physical:null,candidates};
206
194
  }
207
- function parsedTypeScript(root,dir,ts){
208
- const config=path.join(root,dir,'tsconfig.json'),reads=new Set();
195
+ // Files TypeScript may consume from its own include: each directory its
196
+ // include patterns watch for new files (TypeScript's own
197
+ // wildcardDirectories), and each file it lists. node_modules below them is
198
+ // left out, as TypeScript's wildcards leave it out; installed files a program
199
+ // reads are installed inputs. `exclude` entries stay inputs: an import can
200
+ // still reach an excluded file.
201
+ const sourceFiles='*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}';
202
+ function sourcePatterns(root,parsed){
203
+ const patterns=new Set(),excluded=new Set();
204
+ for(const [folder,flags] of Object.entries(parsed.wildcardDirectories||{})){
205
+ // TypeScript 4 lowercases these keys on case-insensitive file systems;
206
+ // the native realpath gives the directory's own spelling.
207
+ let at;try{at=fs.realpathSync.native(folder);}catch{at=path.resolve(folder);}
208
+ if(!within(at,root))throw Error('TypeScript source membership escapes the checkout');
209
+ const prefix=rel(root,at)?rel(root,at)+'/':'';
210
+ if(flags&1){patterns.add(`${prefix}**/${sourceFiles}`);excluded.add(`!${prefix}**/node_modules/**`);}
211
+ else patterns.add(prefix+sourceFiles);
212
+ }
213
+ const included=patternMatcher([...patterns,...excluded]);
214
+ for(const file of parsed.fileNames.map(name=>path.resolve(name))){
215
+ if(!within(file,root))throw Error('TypeScript source membership escapes the checkout');
216
+ if(!included(rel(root,file)))patterns.add(rel(root,file));
217
+ }
218
+ return [...patterns,...excluded];
219
+ }
220
+ function parsedTypeScript(root,dir,ts,configName='tsconfig.json'){
221
+ const config=path.join(root,dir,configName),reads=new Set();
209
222
  const host={...ts.sys,readFile(file){reads.add(path.resolve(file));return ts.sys.readFile(file);},
210
223
  onUnRecoverableConfigFileDiagnostic(){}};
211
224
  const parsed=ts.getParsedCommandLineOfConfigFile(config,{},host);
212
225
  if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
213
- const raw=ts.readConfigFile(config,ts.sys.readFile);
214
- if(raw.error||!Array.isArray(raw.config?.include)||raw.config.include.length!==1||
215
- !/^src(?:$|\/)/.test(raw.config.include[0]))
216
- throw Error('TypeScript source file membership is outside the supported src include');
217
226
  if(parsed.options.plugins?.length||parsed.options.incremental||parsed.options.composite||
218
227
  parsed.projectReferences?.length||parsed.options.paths||parsed.options.baseUrl||
219
228
  parsed.options.rootDirs?.length||parsed.options.typeRoots?.length||
@@ -225,12 +234,13 @@ function parsedTypeScript(root,dir,ts){
225
234
  throw Error('TypeScript module resolver is outside the supported Node contract');
226
235
  for(const file of reads)if(!within(realObservedPath(file),root))
227
236
  throw Error('TypeScript configuration escapes the checkout');
228
- return {parsed,configs:[...reads].map(file=>rel(root,file)).sort()};
237
+ return {parsed,configs:[...reads].map(file=>rel(root,file)).sort(),
238
+ sourcePatterns:sourcePatterns(root,parsed)};
229
239
  }
230
- function listedTypeScriptFiles(root,selected,tsc){
240
+ function listedTypeScriptFiles(root,selected,tsc,configName){
231
241
  // --listFilesOnly resolves the compiler's effective module/file set but
232
242
  // performs no typecheck and emits no project outputs.
233
- const out=spawnSync(process.execPath,[tsc,'-p','tsconfig.json','--listFilesOnly'],
243
+ const out=spawnSync(process.execPath,[tsc,'-p',configName,'--listFilesOnly'],
234
244
  {cwd:path.join(root,selected.dir),env:process.env,encoding:'utf8',
235
245
  // The native NTFS public workspace takes 3-5 s to list ~770 inputs;
236
246
  // 5 s sometimes expired despite bounded, progressing compiler work.
@@ -267,17 +277,23 @@ function sharedHardlinkIn(folder){
267
277
  }
268
278
  return false;
269
279
  }
270
- export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}={}){
280
+ // `run`: the check's {script, part, args}, or a script name for its only run.
281
+ export function pnpmTypecheckInputs(root,workspace,run,{captureQueries=false}={}){
271
282
  const totalStarted=performance.now();
272
283
  root=realObservedPath(root);
273
284
  const install=pnpmInstallInfo(root),members=pnpmWorkspacePackages(root,install.patterns);
274
285
  const selected=workspace==='.'?{name:'.',dir:'.',manifest:'package.json',
275
286
  pkg:read(path.join(root,'package.json'))}:members.get(workspace);
276
287
  if(!selected)throw Error(`workspace ${workspace} is not declared`);
277
- if(!/^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(
278
- selected.pkg.scripts?.[script]?.trim()||'')||
279
- selected.pkg.scripts?.['pre'+script]||selected.pkg.scripts?.['post'+script])
280
- throw Error('typecheck must be one standalone TypeScript invocation');
288
+ // A script name stands for its only run.
289
+ if(typeof run==='string'){const runs=tscRuns(selected.pkg.scripts?.[run]);run={script:run,args:runs?.length===1?runs[0].args:[]};}
290
+ const {run:own,issue}=tscRun(selected.pkg.scripts,run);
291
+ if(issue)throw Error(`typecheck must be standalone TypeScript runs: ${issue}`);
292
+ // `tsc -p .` without --noEmit predates the other forms.
293
+ if(!own.noEmit&&!['-p .','-p tsconfig.json'].includes(run.args.join(' ')))
294
+ throw Error('typecheck must pass --noEmit');
295
+ const configName=runConfig(path.join(root,selected.dir),run.args);
296
+ if(!configName)throw Error('TypeScript project is outside the package');
281
297
  const selectedDir=path.join(root,selected.dir),lookup=createRequire(path.join(selectedDir,'package.json'));
282
298
  let tsPackage;try{tsPackage=realObservedPath(lookup.resolve('typescript/package.json'));}
283
299
  catch{throw Error('installed TypeScript compiler cannot be resolved');}
@@ -286,7 +302,8 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
286
302
  const tsc=path.join(tsRoot,'bin','tsc');
287
303
  if(!exists(tsc))throw Error('installed TypeScript executable is missing');
288
304
  const configStarted=performance.now();
289
- const {configs}=parsedTypeScript(root,selected.dir,ts);
305
+ const {configs,sourcePatterns:sources}=parsedTypeScript(root,selected.dir,ts,configName);
306
+ const isSource=patternMatcher(sources);
290
307
  timing('pnpm.ts_config',configStarted,{configs:configs.length});
291
308
  const closure=new Map(),installed=new Set(),generated=new Set(),
292
309
  links=new Set(),candidates=new Set(),absences=[],instances=new Map();
@@ -336,12 +353,12 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
336
353
  resolutionMs:Math.round(resolutionMs),workspaceLinks:links.size,
337
354
  candidates:candidates.size});
338
355
  const listingStarted=performance.now();
339
- const captured=captureQueries?compilerFiles(tsRoot,path.join(root,selected.dir)):null;
340
- const files=captured?captured.files.map(realObservedPath):listedTypeScriptFiles(root,selected,tsc);
356
+ const captured=captureQueries?compilerFiles(tsRoot,path.join(root,selected.dir),configName):null;
357
+ const files=captured?captured.files.map(realObservedPath):listedTypeScriptFiles(root,selected,tsc,configName);
341
358
  timing('pnpm.ts_input_listing',listingStarted,{files:files.length});
342
359
  const mappingStarted=performance.now();
343
360
  for(const file of files){
344
- if(within(file,path.join(selectedDir,'src'))||
361
+ if(isSource(rel(root,file))||
345
362
  [...closure.values()].some(member=>within(file,path.join(root,member.dir))))continue;
346
363
  const packageRoot=installedPackageRoot(root,file);
347
364
  if(!packageRoot)throw Error(`TypeScript consumes a file outside the observed pnpm contract: ${rel(root,file)}`);
@@ -352,6 +369,16 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
352
369
  }
353
370
  timing('pnpm.input_mapping',mappingStarted,{instances:instances.size,
354
371
  installedPatterns:installed.size});
372
+ // A config extended from an installed package is an installed input too:
373
+ // source patterns leave node_modules out.
374
+ for(const config of configs){
375
+ const file=realObservedPath(path.join(root,config));
376
+ if(!within(file,path.join(root,'node_modules')))continue;
377
+ const packageRoot=installedPackageRoot(root,file);
378
+ if(!packageRoot)throw Error(`TypeScript configuration ${config} is outside an installed package`);
379
+ installed.add(`${rel(root,packageRoot)}/**`);
380
+ installed.add(`!${rel(root,packageRoot)}/node_modules/**`);
381
+ }
355
382
  installed.add(`${rel(root,tsRoot)}/**`);
356
383
  installed.add(`!${rel(root,tsRoot)}/node_modules/**`);
357
384
  if(!instances.has(tsRoot))instances.set(tsRoot,{name:'typescript',physicalRoot:rel(root,tsRoot)});
@@ -360,11 +387,12 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
360
387
  timing('pnpm.hardlink_inspection',sharedStarted,{instances:instances.size,
361
388
  shared:shared.length});
362
389
  const source=[...new Set([...install.configurationInputs,selected.manifest,
363
- ...configs,`${selected.dir==='.'?'':selected.dir+'/'}src/**`,
390
+ ...configs,...sources,
364
391
  ...closure.values().map(member=>member.manifest),...generated])];
365
392
  timing('pnpm.total_discovery',totalStarted,{instances:instances.size,
366
393
  inputFiles:files.length});
367
394
  return {install,workspace:selected,closure:[...closure.values()],source,
395
+ sourcePatterns:sources,args:run.args,
368
396
  installed:[...installed],generated:[...generated],
369
397
  // The read-only state probe consumes this same resolved compiler file set.
370
398
  // It is deliberately omitted from the persisted user-facing input plan.
@@ -7,14 +7,17 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {createHash} from 'node:crypto';
9
9
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
10
+ import {patternMatcher} from './glob.mjs';
10
11
  import {withinPath as within,realObservedPath} from './path-identity.mjs';
12
+ import {runFromArgv} from './typescript-invocation.mjs';
11
13
 
12
- const [rootArg,workspace,script]=process.argv.slice(2);
14
+ const [rootArg,workspace,...rest]=process.argv.slice(2).filter(value=>!value.startsWith('--redue-'));
13
15
  const hash=value=>createHash('sha256').update(value).digest('hex');
14
16
  const digest=file=>hash(fs.readFileSync(file));
15
17
  const fail=code=>{throw Object.assign(Error(code),{code});};
16
18
  try{
17
- if(!rootArg||!workspace||!script)fail('probe-arguments');
19
+ if(!rootArg||!workspace)fail('probe-arguments');
20
+ const run=runFromArgv(rest);
18
21
  const root=realObservedPath(rootArg);
19
22
  if(process.env.NODE_OPTIONS||process.env.NODE_PATH||process.env.BASH_ENV||
20
23
  process.env.ENV||Object.entries(process.env).some(([key,value])=>{
@@ -24,23 +27,25 @@ try{
24
27
  return /^(PNPM_|COREPACK_|npm_config_|DYLD_|TSGO_)/i.test(key);
25
28
  }))
26
29
  fail('pnpm-execution-environment-unsupported');
27
- const contextHash=hash(JSON.stringify([root,workspace,script,process.version]));
30
+ const contextHash=hash(JSON.stringify([root,workspace,run,process.version]));
28
31
  if(contextOnly(contextHash))process.exit(0);
29
- const contract=pnpmTypecheckInputs(root,workspace,script,{captureQueries:process.argv.includes('--redue-checkpoint')});
32
+ const contract=pnpmTypecheckInputs(root,workspace,run,{captureQueries:process.argv.includes('--redue-checkpoint')});
30
33
  if(contract.limitations.length)fail('pnpm-input-coverage-unavailable');
31
34
  const selected=path.join(root,contract.workspace.dir),tsc=contract.tsc;
32
35
  const inputs=contract.listedFiles;
33
36
  if(!inputs.length)fail('typescript-input-list-unavailable');
34
- const inputRoots=[path.join(selected,'src'),...contract.generated.map(pattern=>
37
+ const inputRoots=[...contract.generated.map(pattern=>
35
38
  path.join(root,pattern.slice(0,-3))),...contract.installed.filter(p=>!p.startsWith('!'))
36
39
  .map(pattern=>path.join(root,pattern.slice(0,-3)))];
40
+ const isSource=patternMatcher(contract.sourcePatterns);
37
41
  for(const file of inputs)if(!within(file,root)||
42
+ !isSource(path.relative(root,file).split(path.sep).join('/'))&&
38
43
  !inputRoots.some(boundary=>within(file,boundary)))
39
44
  fail('typescript-input-outside-pnpm-contract');
40
45
  const files=[...new Set(inputs)].sort().map(file=>
41
46
  [hash(path.relative(root,file)),digest(file)]);
42
47
  const configs=contract.configs.map(file=>[hash(file),digest(path.join(root,file))]);
43
- probeResult(hash(JSON.stringify({schema:1,workspace,script,
48
+ probeResult(hash(JSON.stringify({schema:1,workspace,run,
44
49
  files,configs,node:process.version,tsc:digest(tsc),
45
50
  packageManager:contract.install.version,linker:contract.install.linker})),contextHash,contract.discoveryQueries);
46
51
  }catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:
@@ -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 micromatch from './glob.mjs';
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));
@@ -65,9 +67,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
65
67
  const compared=name=>(environment.variables||[]).includes(name)||
66
68
  (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
67
69
  const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
68
- const positive=sourcePatterns.filter(p=>!p.startsWith('!')).map(p=>micromatch.matcher(p,{dot:true}));
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));
70
+ const included=patternMatcher(sourcePatterns);
71
71
  const observed=rel=>Object.hasOwn(files||{},rel);
72
72
  const headers=new Map(),records=[];
73
73
  let names=[];
@@ -336,21 +336,70 @@ export function testWorkspaceDirs(root,pkg){
336
336
  // Each node_modules the recipe observes as installed: the root's and every
337
337
  // workspace package's, relative to the root.
338
338
  const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
339
- // The first installed file with more than one link, or null. A package store
340
- // outside the checkout (pnpm's default on Linux, Yarn's hardlinks modes) can
341
- // change such a file with no event here. Checked at every recorded run: a later
342
- // install that adds links replaces the files, which is a change REDUE sees.
343
- export function sharedInstalledFile(root,project={}){
339
+ // pnpm's content-addressed store, as the install recorded it in .modules.yaml:
340
+ // files/<first 2 hex>/<rest of the content's sha512 hex>, "-exec" for
341
+ // executables. pnpm writes new store files and never changes one in place.
342
+ function pnpmStoreFiles(root){
343
+ try{const {storeDir}=yaml(path.join(root,'node_modules','.modules.yaml'));
344
+ return typeof storeDir==='string'&&path.isAbsolute(storeDir)?path.join(storeDir,'files'):null;}
345
+ catch{return null;}
346
+ }
347
+ const identity=st=>`${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
348
+ const sameFile=(file,st)=>{
349
+ try{const other=fs.statSync(file,{bigint:true});return other.dev===st.dev&&other.ino===st.ino;}
350
+ catch{return false;}};
351
+ // The file's store path relative to `files`, or null when the store does not
352
+ // hold this file at its content's address.
353
+ function storePath(files,file,st){
354
+ const hash=createHash('sha512'),buffer=Buffer.alloc(1<<20),fd=fs.openSync(file,'r');
355
+ try{for(let n;(n=fs.readSync(fd,buffer,0,buffer.length,null))>0;)hash.update(buffer.subarray(0,n));}
356
+ finally{fs.closeSync(fd);}
357
+ const hex=hash.digest('hex'),at=path.join(hex.slice(0,2),hex.slice(2));
358
+ return [at,at+'-exec'].find(candidate=>sameFile(path.join(files,candidate),st))??null;
359
+ }
360
+ // The first installed file with more than one link, as {file, reason}, or
361
+ // null. A package store outside the checkout (pnpm's default on Linux, Yarn's
362
+ // hardlinks modes) can change such a file with no event here. A link into
363
+ // pnpm's own store is accepted: the store's file is named by its content.
364
+ // Checked at every recorded run: a later install that adds links replaces the
365
+ // files, which is a change REDUE sees. `cache` (in owned state) keeps each
366
+ // file found in the store with its identity and store path, so an unchanged
367
+ // file is not hashed again; its store link is still checked.
368
+ export function sharedInstalledFile(root,project={},cache=null){
344
369
  const stack=modulesDirs(project).map(dir=>path.join(root,dir));
370
+ const files=pnpmStoreFiles(root),now=Date.now();
371
+ let known={},changed=false;
372
+ if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
373
+ if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
374
+ const found={};
375
+ const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
345
376
  while(stack.length){
346
377
  const dir=stack.pop();let entries;
347
378
  try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
348
379
  for(const entry of entries){
349
380
  const file=path.join(dir,entry.name);
350
- if(entry.isDirectory())stack.push(file);
351
- else if(entry.isFile())try{if(fs.lstatSync(file).nlink>1)return path.relative(root,file).split(path.sep).join('/');}catch{}
381
+ if(entry.isDirectory()){stack.push(file);continue;}
382
+ if(!entry.isFile())continue;
383
+ let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
384
+ if(st.nlink<=1n)continue;
385
+ if(!files)return shared(file,'has another hard link, and the install records no pnpm store');
386
+ const key=`${st.dev}:${st.ino}`,cached=known[key];
387
+ let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
388
+ sameFile(path.join(files,cached[1]),st)?cached[1]:null;
389
+ if(!at){
390
+ try{at=storePath(files,file,st);}
391
+ catch(e){return shared(file,`could not be checked against pnpm's store (${e.code||e.message})`);}
392
+ if(!at)return shared(file,"has another hard link outside pnpm's store");
393
+ changed=true;
394
+ }
395
+ // A file changed within the racy window could change again unseen in
396
+ // the same timestamp tick; it is hashed again next time.
397
+ if(racyUntil(st)<=now)found[key]=[identity(st),at];
398
+ else changed=true;
352
399
  }
353
400
  }
401
+ if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
402
+ try{atomicJson(cache,{files,entries:found});}catch{}
354
403
  return null;
355
404
  }
356
405
  // The runner a package's script reaches: Node's lookup from the package
@@ -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,'tsconfig.json'),true);
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 config=path.join(root,'tsconfig.json'),raw=fs.readFileSync(config,'utf8');
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(config);
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
+ }
@@ -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,'tsconfig.json'),{},
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.script);
15
- if(selected.command?.length!==3||selected.command[0]!==process.execPath||
16
- selected.command[1]!==compiler.entry||selected.command[2]!=='--noEmit'||
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&&argv[3]===selected.script))issues.push('direct compiler probe missing');
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
- const declared=contract.workspace.pkg.scripts[script].trim().split(/\s+/).slice(1);
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 micromatch from './glob.mjs';
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 positive=patterns.filter(p=>!p.startsWith('!')),negative=patterns.filter(p=>p.startsWith('!')).map(p=>p.slice(1));
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&&micromatch.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'))}));