@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.
@@ -10,9 +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,yaml} from './pnpm-inputs.mjs';
17
+ import {workspaceManifests} from './workspace-manifests.mjs';
18
+ import {atomicJson} from './state-store.mjs';
19
+ import {racyUntil} from './path-identity.mjs';
16
20
 
17
21
  const digest=value=>createHash('sha256').update(value).digest('hex');
18
22
  export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
@@ -39,10 +43,10 @@ const issueLimit=20,factLimit=10000;
39
43
 
40
44
  // The guard file the recorder reads: where to write, what counts as the
41
45
  // checkout and as temporary space, and digests of the caller's environment.
42
- export function testGuardConfig(dir,root,env=process.env) {
46
+ export function testGuardConfig(dir,root,env=process.env,project={}) {
43
47
  const tmp=[...new Set([path.resolve(os.tmpdir()),fs.realpathSync(os.tmpdir())])];
44
48
  const real=dir=>{try{return fs.realpathSync(dir);}catch{return dir;}};
45
- return {schema:1,dir,root,tmp,runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
49
+ return {schema:1,dir,root,tmp,installed:modulesDirs(project).map(at=>path.join(root,at)),runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
46
50
  env:Object.fromEntries(Object.entries(env).filter(([,value])=>typeof value==='string').map(([key,value])=>[key,digest(value)]))};
47
51
  }
48
52
 
@@ -63,9 +67,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
63
67
  const compared=name=>(environment.variables||[]).includes(name)||
64
68
  (environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
65
69
  const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
66
- const positive=sourcePatterns.filter(p=>!p.startsWith('!')).map(p=>micromatch.matcher(p,{dot:true}));
67
- const negative=sourcePatterns.filter(p=>p.startsWith('!')).map(p=>micromatch.matcher(p.slice(1),{dot:true}));
68
- const included=rel=>positive.some(fn=>fn(rel))&&!negative.some(fn=>fn(rel));
70
+ const included=patternMatcher(sourcePatterns);
69
71
  const observed=rel=>Object.hasOwn(files||{},rel);
70
72
  const headers=new Map(),records=[];
71
73
  let names=[];
@@ -262,48 +264,199 @@ export function testRecipeScript(scripts){
262
264
  return near?{issue:issue(near)}:{};
263
265
  }
264
266
  const majorOf=version=>Number(String(version).split('.')[0]);
265
- // The runner's executable inside the checkout's node_modules.
267
+ // The runner's executable inside the checkout's node_modules. pnpm links
268
+ // node_modules/RUNNER into node_modules/.pnpm; the files stay in node_modules.
266
269
  export function testRunnerEntry(root,runner){
267
270
  if(!runners[runner])fail('test-runner-unsupported');
268
271
  const dir=path.join(root,'node_modules',runner);
269
272
  let pkg,real;
270
273
  try{real=fs.realpathSync(dir);pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}
271
274
  catch{fail('test-runner-missing');}
272
- if(real!==dir||pkg.name!==runner)fail('test-runner-layout-unsupported');
275
+ const modules=fs.realpathSync(path.join(root,'node_modules'));
276
+ if(!real.startsWith(modules+path.sep)||pkg.name!==runner)fail('test-runner-layout-unsupported');
273
277
  const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
274
278
  const entry=typeof bin==='string'?path.resolve(real,bin):null;
275
279
  if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
276
280
  return entry;
277
281
  }
278
- // The project facts the recipe needs, or an error code.
279
- export function testProject(root,script){
282
+ // The package manager of a checkout whose install the recipe observes in
283
+ // node_modules: npm, pnpm 9-12 (isolated or hoisted), or Yarn with the
284
+ // node-modules linker. One lockfile kind, agreeing with any declaration.
285
+ // Workspaces are refused unless `workspaces` is set.
286
+ export function testPackageManager(root,pkg,{workspaces=false}={}){
287
+ const has=file=>fs.existsSync(path.join(root,file));
288
+ const kinds=new Set([has('package-lock.json')||has('npm-shrinkwrap.json')?'npm':null,has('pnpm-lock.yaml')?'pnpm':null,
289
+ has('yarn.lock')?'yarn':null,has('bun.lock')||has('bun.lockb')?'bun':null].filter(Boolean));
290
+ const declared=/^(npm|pnpm|yarn|bun)@/.exec(pkg.packageManager||'')?.[1];
291
+ if(pkg.packageManager!==undefined&&!declared)fail('test-package-manager-unsupported');
292
+ if(declared)kinds.add(declared);
293
+ const name=kinds.size===1?[...kinds][0]:null;
294
+ if(!name||!has({npm:'package-lock.json',pnpm:'pnpm-lock.yaml',yarn:'yarn.lock',bun:'bun.lock'}[name])&&
295
+ !(name==='npm'&&has('npm-shrinkwrap.json')))fail('test-package-manager-unsupported');
296
+ if(pkg.workspaces&&!workspaces)fail('test-workspace-unsupported');
297
+ if(name==='pnpm'){
298
+ // pnpm 10+ keeps settings in pnpm-workspace.yaml; only `packages` makes a workspace.
299
+ let install;try{install=pnpmInstallInfo(root);}catch{fail('test-pnpm-install-unsupported');}
300
+ if(install.patterns.length&&!workspaces)fail('test-workspace-unsupported');
301
+ }
302
+ if(name==='yarn'){
303
+ // Yarn 2+ needs top-level `nodeLinker: node-modules` and the classic nmMode
304
+ // (no hard links); Yarn 1 always installs node_modules.
305
+ const rc=has('.yarnrc.yml')?fs.readFileSync(path.join(root,'.yarnrc.yml'),'utf8'):null;
306
+ const top=key=>rc?new RegExp(`^${key}\\s*:\\s*['"]?([\\w-]+)`,'m').exec(rc)?.[1]:undefined;
307
+ const version=/^yarn@(\d+)\./.exec(pkg.packageManager||'')?.[1];
308
+ if(!version||(version==='1'?rc!==null:top('nodeLinker')!=='node-modules'||![undefined,'classic'].includes(top('nmMode'))))
309
+ fail('test-yarn-install-unsupported');
310
+ }
311
+ if(name==='bun')fail('test-package-manager-unsupported');
312
+ return name;
313
+ }
314
+ // What a recorded run needs, from the check itself rather than the checkout:
315
+ // the package directory, the workspace node_modules its installed inputs
316
+ // list, and the runner and arguments of the package's script. A package
317
+ // added since init is then not treated as installed, so reads there are
318
+ // unobserved and make the run UNVERIFIED.
319
+ export function testRunFacts(root,check){
320
+ const dir=check.cwd&&check.cwd!=='.'?check.cwd:'.';
321
+ const dirs=(check.installedInputs||[]).map(p=>/^([^!*]+)\/node_modules\/\*\*$/.exec(p)?.[1]).filter(Boolean);
322
+ const own=JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
323
+ if(typeof own.scripts?.[check.script]!=='string')fail('test-script-changed');
324
+ return {...testScript(own.scripts[check.script]),dir,dirs};
325
+ }
326
+ // The checkout's workspace patterns: pnpm's `packages`, else package.json's.
327
+ export function testWorkspacePatterns(root,pkg){
328
+ return fs.existsSync(path.join(root,'pnpm-lock.yaml'))?pnpmProjectInfo(root).patterns:
329
+ Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages??[];
330
+ }
331
+ // The checkout's workspace package directories, [] for a single package.
332
+ export function testWorkspaceDirs(root,pkg){
333
+ const patterns=testWorkspacePatterns(root,pkg);
334
+ return patterns.length?workspaceManifests(root,patterns).map(({file})=>path.posix.dirname(file)):[];
335
+ }
336
+ // Each node_modules the recipe observes as installed: the root's and every
337
+ // workspace package's, relative to the root.
338
+ const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
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){
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});
376
+ while(stack.length){
377
+ const dir=stack.pop();let entries;
378
+ try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
379
+ for(const entry of entries){
380
+ const file=path.join(dir,entry.name);
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;
399
+ }
400
+ }
401
+ if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
402
+ try{atomicJson(cache,{files,entries:found});}catch{}
403
+ return null;
404
+ }
405
+ // The runner a package's script reaches: Node's lookup from the package
406
+ // directory up to the root, skipping candidates that are not the runner. Its
407
+ // files must be in an observed node_modules (`dirs`: workspace packages).
408
+ // `logical` is where the lookup found it, before links, relative to the root.
409
+ export function testRunnerAt(root,dir,runner,dirs=[]){
410
+ if(dir==='.')return {entry:testRunnerEntry(root,runner),logical:`node_modules/${runner}`};
411
+ if(!runners[runner])fail('test-runner-unsupported');
412
+ const rootReal=fs.realpathSync(root),observed=modulesDirs({dirs}).map(at=>path.join(rootReal,at)+path.sep);
413
+ for(let at=dir;;at=path.posix.dirname(at)){
414
+ const logical=at==='.'?`node_modules/${runner}`:`${at}/node_modules/${runner}`;
415
+ let real,pkg;
416
+ try{real=fs.realpathSync(path.join(root,logical));pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}catch{}
417
+ if(pkg?.name===runner){
418
+ if(!observed.some(prefix=>real.startsWith(prefix)))fail('test-runner-layout-unsupported');
419
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
420
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
421
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
422
+ return {entry,logical};
423
+ }
424
+ if(at==='.')fail('test-runner-missing');
425
+ }
426
+ }
427
+ // The project facts the recipe needs, or an error code. `dir` is a workspace
428
+ // package's directory; there the recipe supports Vitest only.
429
+ export function testProject(root,script,dir='.'){
280
430
  const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
281
- if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
282
- !['package-lock.json','npm-shrinkwrap.json'].some(file=>fs.existsSync(path.join(root,file)))||
283
- ['yarn.lock','pnpm-lock.yaml','bun.lock','bun.lockb'].some(file=>fs.existsSync(path.join(root,file))))
284
- fail('test-npm-project-unsupported');
285
- if(typeof pkg.scripts?.[script]!=='string'||pkg.scripts['pre'+script]||pkg.scripts['post'+script])fail('test-script-changed');
286
- const {runner,args}=testScript(pkg.scripts[script]);
287
- const entry=testRunnerEntry(root,runner);
288
- const version=JSON.parse(fs.readFileSync(path.join(root,'node_modules',runner,'package.json'),'utf8')).version;
431
+ testPackageManager(root,pkg,{workspaces:dir!=='.'});
432
+ const dirs=dir==='.'?[]:testWorkspaceDirs(root,pkg);
433
+ if(dir!=='.'&&!dirs.includes(dir))fail('test-workspace-unsupported');
434
+ const own=dir==='.'?pkg:JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
435
+ if(typeof own.scripts?.[script]!=='string'||own.scripts['pre'+script]||own.scripts['post'+script])fail('test-script-changed');
436
+ const {runner,args}=testScript(own.scripts[script]);
437
+ if(dir!=='.'&&runner!=='vitest')fail('test-runner-unsupported');
438
+ const {entry,logical}=testRunnerAt(root,dir,runner,dirs);
439
+ const version=JSON.parse(fs.readFileSync(path.join(root,logical,'package.json'),'utf8')).version;
289
440
  if(!runners[runner].majors.includes(majorOf(version)))fail('test-runner-version-unsupported');
290
441
  if(runner==='jest'){
291
442
  let util=null;try{util=JSON.parse(fs.readFileSync(path.join(root,'node_modules/jest-util/package.json'),'utf8')).version;}catch{}
292
443
  if(majorOf(util)!==29||!fs.existsSync(path.join(root,'node_modules/jest-util/build/createProcessObject.js')))
293
444
  fail('test-runner-layout-unsupported');
294
445
  }
295
- return {runner,version,args,entry};
446
+ return {runner,version,args,entry,dir,dirs,logical};
296
447
  }
297
448
  // Inputs: the whole checkout except documentation and the coverage report a
298
449
  // --coverage run writes. Git-ignored files the run creates are handled per
299
- // run (cli.mjs). Installed files are observed by identity; Vite's
300
- // caches inside node_modules are written by every Vitest run.
301
- export function testCheckInputs({runner,args}){
302
- return {inputs:['**','!node_modules/**','!.git/**','!**/*.md','!**/*.mdx',
303
- ...(args.includes('--coverage')?['!coverage/**']:[])],
450
+ // run (cli.mjs). Installed files, in the root's node_modules and every
451
+ // workspace package's, are observed by identity; Vite's caches inside them
452
+ // are written by every Vitest run.
453
+ export function testCheckInputs({runner,args,dir='.',dirs=[],logical=`node_modules/${runner}`}){
454
+ const modules=modulesDirs({dirs});
455
+ return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**','!**/*.md','!**/*.mdx',
456
+ ...(args.includes('--coverage')?[`!${dir==='.'?'':dir+'/'}coverage/**`]:[])],
304
457
  // Vitest's typecheck mode keeps tsc's build info in its own dist directory.
305
- installedInputs:['node_modules/**',...(runner==='vitest'?['!node_modules/.vite/**','!node_modules/.vite-temp/**','!node_modules/.vitest/**',
306
- '!node_modules/vitest/dist/tsconfig.tmp.tsbuildinfo']:[])]};
458
+ installedInputs:modules.flatMap(at=>[`${at}/**`,...(runner==='vitest'?[`!${at}/.vite/**`,`!${at}/.vite-temp/**`,`!${at}/.vitest/**`]:[])])
459
+ .concat(runner==='vitest'?[`!${logical}/dist/tsconfig.tmp.tsbuildinfo`]:[])};
307
460
  }
308
461
  // The coverage directory a --coverage run may read back: Jest reads every
309
462
  // .js file under its roots, an earlier report's included, to map dependencies,
@@ -311,21 +464,26 @@ export function testCheckInputs({runner,args}){
311
464
  // when no configuration names thresholds (which make the report decide the
312
465
  // result) or another report directory. Configuration can be code, so this
313
466
  // reads its text.
314
- export function coverageReadable(root,{runner,args}){
467
+ export function coverageReadable(checkout,{runner,args,dir='.'}){
315
468
  if(!args.includes('--coverage'))return null;
469
+ // A workspace package's configuration and report live in its directory.
470
+ const root=path.join(checkout,dir);
316
471
  const names=runner==='jest'?['jest.config.js','jest.config.ts','jest.config.mjs','jest.config.cjs','jest.config.json']:
317
472
  ['vitest.config.ts','vitest.config.mts','vitest.config.cts','vitest.config.js','vitest.config.mjs','vitest.config.cjs',
318
473
  'vite.config.ts','vite.config.mts','vite.config.cts','vite.config.js','vite.config.mjs','vite.config.cjs',
319
474
  'vitest.workspace.ts','vitest.workspace.js','vitest.workspace.json'];
320
475
  const at=args.findIndex(arg=>arg==='--config'||arg==='-c'),inline=args.find(arg=>/^(?:--config|-c)=/.test(arg));
321
476
  const given=at>=0?args[at+1]:inline?.split(/=(.*)/s)[1];
322
- const texts=[];
323
- for(const name of [...names,...(given?[given]:[])]){
324
- try{texts.push(fs.readFileSync(path.resolve(root,name),'utf8'));}catch{}
477
+ // Vitest finds its configuration in the package directory or a parent.
478
+ const texts=[],folders=[];
479
+ for(let at=dir;;at=path.posix.dirname(at)){folders.push(path.join(checkout,at));if(at==='.')break;}
480
+ for(const name of [...names,...(given?[given]:[])])for(const folder of folders){
481
+ try{texts.push(fs.readFileSync(path.resolve(folder,name),'utf8'));}catch{}
325
482
  }
326
483
  try{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
327
484
  if(runner==='jest'&&pkg.jest)texts.push(JSON.stringify(pkg.jest));}catch{return null;}
328
- return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:'coverage';
485
+ return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:
486
+ dir==='.'?'coverage':`${dir}/coverage`;
329
487
  }
330
488
  // The caller's environment, when it would load code before the recorder or
331
489
  // the Node version is not one the recipe supports. Compared variables are
@@ -345,13 +503,17 @@ export function qualifyTests(root,selected){
345
503
  const issues=[];
346
504
  if(!['darwin','linux'].includes(process.platform))issues.push('the test recipe records runs on macOS and Linux only');
347
505
  try{
348
- const project=testProject(root,selected.script);
349
- if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args])||
350
- selected.cwd&&selected.cwd!=='.')issues.push('test runner invocation changed; run redue init again');
506
+ const dir=selected.cwd&&selected.cwd!=='.'?selected.cwd:'.';
507
+ if(dir!=='.'&&!selected.workspace)throw Object.assign(Error(),{code:'a test check in a directory needs its workspace'});
508
+ const project=testProject(root,selected.script,dir);
509
+ if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args]))
510
+ issues.push('test runner invocation changed; run redue init again');
351
511
  // Every checkout file but installed ones is a content input; the recorder
352
512
  // skips node_modules because it is observed as installed files.
353
- const positives=(selected.inputs||[]).filter(p=>!p.startsWith('!'));
354
- if(positives.length!==1||positives[0]!=='**'||!selected.inputs.includes('!node_modules/**')||
513
+ if(dir!=='.'&&JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8')).name!==selected.workspace)
514
+ issues.push('the test check names another workspace package than its directory holds; run redue init again');
515
+ const expected=testCheckInputs(project),sorted=list=>JSON.stringify([...(list||[])].sort());
516
+ if(sorted(selected.inputs)!==sorted(expected.inputs)||
355
517
  JSON.stringify([...(selected.installedInputs||[])].sort())!==JSON.stringify([...testCheckInputs(project).installedInputs].sort()))
356
518
  issues.push('test inputs must be the whole checkout, with node_modules observed as installed files; run redue init again');
357
519
  }catch(e){issues.push(e.code||'test runner prerequisites unavailable');}
@@ -24,7 +24,8 @@ function install(){
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
  emit(['H',{pid:process.pid,ppid:process.ppid,threadId}]);
27
- const sep=path.sep,root=config.root,installed=path.join(root,'node_modules'),
27
+ // Every node_modules observed as installed: the root's and each workspace package's.
28
+ const sep=path.sep,root=config.root,installed=Array.isArray(config.installed)?config.installed:[path.join(root,'node_modules')],
28
29
  tmp=config.tmp;
29
30
  const within=(file,dir)=>file===dir||file.startsWith(dir+sep);
30
31
  const digest=value=>createHash('sha256').update(value).digest('hex');
@@ -85,7 +86,7 @@ function install(){
85
86
  if(full===undefined)return;
86
87
  if(full===null){if(!['produce','touch','mkdir'].includes(role))failed(`${role} of an unsupported path argument`);return;}
87
88
  // Installed files are observed by identity.
88
- if(within(full,installed))return;
89
+ if(installed.some(dir=>within(full,dir)))return;
89
90
  if(role==='produce'){if(!error)produced.add(full);if(!error&&within(full,root))once(['P',rel(full)]);return;}
90
91
  // A directory the run created held nothing before it, so everything in
91
92
  // it is the run's output, whichever process reads it.
@@ -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'))
@@ -0,0 +1,22 @@
1
+ // Workspace package manifests: package.json files Git tracks or would add,
2
+ // in directories that match the workspace patterns, outside node_modules.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import {spawnSync} from 'node:child_process';
6
+ import {patternMatcher} from './glob.mjs';
7
+
8
+ export function workspaceManifests(root,patterns) {
9
+ const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
10
+ '-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
11
+ maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
12
+ env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
13
+ if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
14
+ // `!pattern` entries exclude, as in pnpm-workspace.yaml.
15
+ const included=patternMatcher(patterns);
16
+ const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
17
+ const dir=path.posix.dirname(file);
18
+ return dir!=='.'&&!dir.split('/').includes('node_modules')&&included(dir);
19
+ }).sort();
20
+ if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
21
+ return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));
22
+ }