@redue/cli 0.1.0-alpha.15 → 0.1.0-alpha.17

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.
@@ -1,4 +1,4 @@
1
- // Pinned pnpm 12 node-modules installation provider. The engine sees only
1
+ // pnpm 9-12 node-modules installation provider. The engine sees only
2
2
  // repository-relative input sets and resolution relationships, never pnpm
3
3
  // virtual-store names as semantic identities.
4
4
  import {probeTimeLeft} from './probe-deadline.mjs';
@@ -27,40 +27,81 @@ function safePatterns(values){
27
27
  throw Error('pnpm workspace patterns are unsupported');
28
28
  return values;
29
29
  }
30
+ // The supported pnpm majors. pnpm 9 reads settings from .npmrc, pnpm 10 from
31
+ // pnpm-workspace.yaml first and then .npmrc, pnpm 11 and 12 from
32
+ // pnpm-workspace.yaml only.
33
+ export const pnpmMajors=[9,10,11,12];
34
+ // Settings that move or copy installed files, or run code during install,
35
+ // where the contract does not look; by pnpm-workspace.yaml and .npmrc name.
36
+ // Other settings change only what gets installed, and the installed files are
37
+ // what the contract observes. These are refused when either file sets them,
38
+ // whichever file the installed pnpm reads. Only the project's files are read:
39
+ // user or global rc files and npm_config_* variables at install time are not.
40
+ const refused=[['modulesDir','modules-dir'],['virtualStoreDir','virtual-store-dir'],['lockfileDir','lockfile-dir'],
41
+ ['enableGlobalVirtualStore','enable-global-virtual-store'],['injectWorkspacePackages','inject-workspace-packages'],
42
+ ['sharedWorkspaceLockfile','shared-workspace-lockfile'],['pnpmfile','pnpmfile'],['globalPnpmfile','global-pnpmfile'],
43
+ ['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
+ // Whether a refused setting is set: present at all, except that only
59
+ // sharedWorkspaceLockfile=false and injectWorkspacePackages=true move files.
60
+ const setting=(key,value)=>value!==undefined&&
61
+ (key==='sharedWorkspaceLockfile'?value===false:key==='injectWorkspacePackages'?value===true:true);
30
62
  export function pnpmProjectInfo(root){
31
63
  root=realObservedPath(root);
32
64
  const pkg=read(path.join(root,'package.json'));
33
- const pin=/^pnpm@(12\.\d+\.\d+)(?:\+[^\s]+)?$/.exec(pkg.packageManager||'');
34
- if(!pin)throw Error('an exact pnpm 12 packageManager pin is required');
65
+ // A pin is optional; when present it names pnpm 9-12, and the install must match it.
66
+ const pin=pkg.packageManager===undefined?null:/^pnpm@((\d+)\.\d+\.\d+)(?:\+[^\s]+)?$/.exec(pkg.packageManager);
67
+ if(pkg.packageManager!==undefined&&(!pin||!pnpmMajors.includes(Number(pin[2]))))
68
+ throw Error(`packageManager ${JSON.stringify(String(pkg.packageManager).split('+')[0])} is outside pnpm ${pnpmMajors[0]}-${pnpmMajors.at(-1)}`);
35
69
  const workspaceFile=path.join(root,'pnpm-workspace.yaml');
36
- const settings=exists(workspaceFile)?yaml(workspaceFile):{};
37
- const patterns=safePatterns(settings.packages||[]);
38
- const linker=settings.nodeLinker||'isolated';
39
- return {version:pin[1],linker,patterns,settings};
70
+ const workspace=exists(workspaceFile)?yaml(workspaceFile):{},rc=npmrc(path.join(root,'.npmrc'));
71
+ const patterns=safePatterns(workspace.packages||[]);
72
+ // The linker the given pnpm major reads; without a major, the one any reads.
73
+ const linkerFor=major=>(major===9?rc['node-linker']:major>=11?workspace.nodeLinker:
74
+ workspace.nodeLinker??rc['node-linker'])||'isolated';
75
+ const major=pin?Number(pin[2]):null;
76
+ return {version:pin?.[1]??null,linker:linkerFor(major),linkerFor,patterns,settings:workspace,
77
+ refused:refused.filter(([key,name])=>setting(key,workspace[key])||setting(key,rc[name])).map(([key])=>key)};
40
78
  }
41
79
  export function pnpmInstallInfo(root){
42
80
  const started=performance.now();
43
81
  root=realObservedPath(root);
44
- const pkg=read(path.join(root,'package.json'));
45
- const {version,linker,patterns,settings}=pnpmProjectInfo(root);
82
+ const {version,linkerFor,patterns,settings,refused}=pnpmProjectInfo(root);
46
83
  const workspaceFile=path.join(root,'pnpm-workspace.yaml');
47
- if(!['isolated','hoisted'].includes(linker))throw Error(`pnpm ${linker} linker is recording-only`);
48
- if(settings.modulesDir||settings.virtualStoreDir||settings.enableGlobalVirtualStore)
49
- throw Error('custom pnpm modules or virtual-store location is recording-only');
50
- if(settings.injectWorkspacePackages)
51
- throw Error('injected workspace dependencies are recording-only');
52
- if(settings.sharedWorkspaceLockfile===false)
53
- throw Error('separate workspace lockfiles are recording-only');
54
- if(exists(path.join(root,'.pnpmfile.cjs'))||exists(path.join(root,'.pnpmfile.mjs')))
84
+ if(refused.some(key=>['pnpmfile','globalPnpmfile','configDependencies'].includes(key))||
85
+ exists(path.join(root,'.pnpmfile.cjs'))||exists(path.join(root,'.pnpmfile.mjs')))
55
86
  throw Error('custom pnpm installation hooks need review');
87
+ if(refused.some(key=>['modulesDir','virtualStoreDir','lockfileDir','enableGlobalVirtualStore'].includes(key)))
88
+ throw Error('custom pnpm modules or virtual-store location is recording-only');
89
+ if(refused.includes('injectWorkspacePackages'))throw Error('injected workspace dependencies are recording-only');
90
+ if(refused.includes('sharedWorkspaceLockfile'))throw Error('separate workspace lockfiles are recording-only');
56
91
  const modulesFile=path.join(root,'node_modules','.modules.yaml');
57
92
  if(!exists(modulesFile))throw Error('installed pnpm module metadata is missing');
58
93
  const modules=yaml(modulesFile);
59
- if(modules.nodeLinker!==linker||modules.packageManager!==pkg.packageManager)
94
+ const installed=/^pnpm@((\d+)\.\d+\.\d+)$/.exec(String(modules.packageManager));
95
+ if(!installed||!pnpmMajors.includes(Number(installed[2])))
96
+ throw Error(`installed by ${JSON.stringify(String(modules.packageManager))}, outside pnpm ${pnpmMajors[0]}-${pnpmMajors.at(-1)}`);
97
+ const linker=linkerFor(Number(installed[2]));
98
+ if(!['isolated','hoisted'].includes(linker))throw Error(`pnpm ${linker} linker is recording-only`);
99
+ if(modules.nodeLinker!==linker||version!==null&&installed[1]!==version)
60
100
  throw Error('installed pnpm linker or version differs from project configuration');
61
101
  const virtualStore=path.join(root,'node_modules','.pnpm');
62
- // pnpm 12 on Windows can omit virtualStoreDir when using its project-local
63
- // default. Accept only the observed, ordinary local directory in that case.
102
+ // pnpm can omit virtualStoreDir when using its project-local default
103
+ // (seen with pnpm 12 on Windows). Accept only the observed, ordinary local
104
+ // directory in that case.
64
105
  const declared=modules.virtualStoreDir;
65
106
  const selected=declared===undefined?virtualStore:
66
107
  path.resolve(path.dirname(modulesFile),declared);
@@ -72,18 +113,8 @@ export function pnpmInstallInfo(root){
72
113
  throw Error('nonstandard or incomplete project-local pnpm virtual store');
73
114
  if(!exists(path.join(root,'pnpm-lock.yaml')))
74
115
  throw Error('pnpm lockfile is missing');
75
- if(exists(path.join(root,'.npmrc'))){
76
- // pnpm 12 reads project settings from pnpm-workspace.yaml. Registry/auth
77
- // entries affect acquisition, not a completed local TypeScript invocation.
78
- for(const line of fs.readFileSync(path.join(root,'.npmrc'),'utf8').split(/\r?\n/)){
79
- const trimmed=line.trim();if(!trimmed||/^[#;]/.test(trimmed))continue;
80
- const key=trimmed.split('=')[0].trim();
81
- if(key!=='registry'&&!/^\/\/[^\s]+\/:_authToken$/.test(key))
82
- throw Error('project .npmrc has execution-relevant pnpm settings requiring review');
83
- }
84
- }
85
116
  timing('pnpm.install_metadata',started);
86
- return {version,linker,patterns,settings,modulesFile,
117
+ return {version:installed[1],linker,patterns,settings,modulesFile,
87
118
  configurationInputs:['package.json','pnpm-lock.yaml',
88
119
  ...(exists(workspaceFile)?['pnpm-workspace.yaml']:[]),
89
120
  ...(exists(path.join(root,'.npmrc'))?['.npmrc']:[]),
@@ -13,6 +13,8 @@ import {spawnSync} from 'node:child_process';
13
13
  import micromatch 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';
17
+ import {workspaceManifests} from './workspace-manifests.mjs';
16
18
 
17
19
  const digest=value=>createHash('sha256').update(value).digest('hex');
18
20
  export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
@@ -39,10 +41,10 @@ const issueLimit=20,factLimit=10000;
39
41
 
40
42
  // The guard file the recorder reads: where to write, what counts as the
41
43
  // checkout and as temporary space, and digests of the caller's environment.
42
- export function testGuardConfig(dir,root,env=process.env) {
44
+ export function testGuardConfig(dir,root,env=process.env,project={}) {
43
45
  const tmp=[...new Set([path.resolve(os.tmpdir()),fs.realpathSync(os.tmpdir())])];
44
46
  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)]))],
47
+ 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
48
  env:Object.fromEntries(Object.entries(env).filter(([,value])=>typeof value==='string').map(([key,value])=>[key,digest(value)]))};
47
49
  }
48
50
 
@@ -262,48 +264,150 @@ 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
+ // 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={}){
344
+ const stack=modulesDirs(project).map(dir=>path.join(root,dir));
345
+ while(stack.length){
346
+ const dir=stack.pop();let entries;
347
+ try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
348
+ for(const entry of entries){
349
+ 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{}
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+ // The runner a package's script reaches: Node's lookup from the package
357
+ // directory up to the root, skipping candidates that are not the runner. Its
358
+ // files must be in an observed node_modules (`dirs`: workspace packages).
359
+ // `logical` is where the lookup found it, before links, relative to the root.
360
+ export function testRunnerAt(root,dir,runner,dirs=[]){
361
+ if(dir==='.')return {entry:testRunnerEntry(root,runner),logical:`node_modules/${runner}`};
362
+ if(!runners[runner])fail('test-runner-unsupported');
363
+ const rootReal=fs.realpathSync(root),observed=modulesDirs({dirs}).map(at=>path.join(rootReal,at)+path.sep);
364
+ for(let at=dir;;at=path.posix.dirname(at)){
365
+ const logical=at==='.'?`node_modules/${runner}`:`${at}/node_modules/${runner}`;
366
+ let real,pkg;
367
+ try{real=fs.realpathSync(path.join(root,logical));pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}catch{}
368
+ if(pkg?.name===runner){
369
+ if(!observed.some(prefix=>real.startsWith(prefix)))fail('test-runner-layout-unsupported');
370
+ const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
371
+ const entry=typeof bin==='string'?path.resolve(real,bin):null;
372
+ if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
373
+ return {entry,logical};
374
+ }
375
+ if(at==='.')fail('test-runner-missing');
376
+ }
377
+ }
378
+ // The project facts the recipe needs, or an error code. `dir` is a workspace
379
+ // package's directory; there the recipe supports Vitest only.
380
+ export function testProject(root,script,dir='.'){
280
381
  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;
382
+ testPackageManager(root,pkg,{workspaces:dir!=='.'});
383
+ const dirs=dir==='.'?[]:testWorkspaceDirs(root,pkg);
384
+ if(dir!=='.'&&!dirs.includes(dir))fail('test-workspace-unsupported');
385
+ const own=dir==='.'?pkg:JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
386
+ if(typeof own.scripts?.[script]!=='string'||own.scripts['pre'+script]||own.scripts['post'+script])fail('test-script-changed');
387
+ const {runner,args}=testScript(own.scripts[script]);
388
+ if(dir!=='.'&&runner!=='vitest')fail('test-runner-unsupported');
389
+ const {entry,logical}=testRunnerAt(root,dir,runner,dirs);
390
+ const version=JSON.parse(fs.readFileSync(path.join(root,logical,'package.json'),'utf8')).version;
289
391
  if(!runners[runner].majors.includes(majorOf(version)))fail('test-runner-version-unsupported');
290
392
  if(runner==='jest'){
291
393
  let util=null;try{util=JSON.parse(fs.readFileSync(path.join(root,'node_modules/jest-util/package.json'),'utf8')).version;}catch{}
292
394
  if(majorOf(util)!==29||!fs.existsSync(path.join(root,'node_modules/jest-util/build/createProcessObject.js')))
293
395
  fail('test-runner-layout-unsupported');
294
396
  }
295
- return {runner,version,args,entry};
397
+ return {runner,version,args,entry,dir,dirs,logical};
296
398
  }
297
399
  // Inputs: the whole checkout except documentation and the coverage report a
298
400
  // --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/**']:[])],
401
+ // run (cli.mjs). Installed files, in the root's node_modules and every
402
+ // workspace package's, are observed by identity; Vite's caches inside them
403
+ // are written by every Vitest run.
404
+ export function testCheckInputs({runner,args,dir='.',dirs=[],logical=`node_modules/${runner}`}){
405
+ const modules=modulesDirs({dirs});
406
+ return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**','!**/*.md','!**/*.mdx',
407
+ ...(args.includes('--coverage')?[`!${dir==='.'?'':dir+'/'}coverage/**`]:[])],
304
408
  // 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']:[])]};
409
+ installedInputs:modules.flatMap(at=>[`${at}/**`,...(runner==='vitest'?[`!${at}/.vite/**`,`!${at}/.vite-temp/**`,`!${at}/.vitest/**`]:[])])
410
+ .concat(runner==='vitest'?[`!${logical}/dist/tsconfig.tmp.tsbuildinfo`]:[])};
307
411
  }
308
412
  // The coverage directory a --coverage run may read back: Jest reads every
309
413
  // .js file under its roots, an earlier report's included, to map dependencies,
@@ -311,21 +415,26 @@ export function testCheckInputs({runner,args}){
311
415
  // when no configuration names thresholds (which make the report decide the
312
416
  // result) or another report directory. Configuration can be code, so this
313
417
  // reads its text.
314
- export function coverageReadable(root,{runner,args}){
418
+ export function coverageReadable(checkout,{runner,args,dir='.'}){
315
419
  if(!args.includes('--coverage'))return null;
420
+ // A workspace package's configuration and report live in its directory.
421
+ const root=path.join(checkout,dir);
316
422
  const names=runner==='jest'?['jest.config.js','jest.config.ts','jest.config.mjs','jest.config.cjs','jest.config.json']:
317
423
  ['vitest.config.ts','vitest.config.mts','vitest.config.cts','vitest.config.js','vitest.config.mjs','vitest.config.cjs',
318
424
  'vite.config.ts','vite.config.mts','vite.config.cts','vite.config.js','vite.config.mjs','vite.config.cjs',
319
425
  'vitest.workspace.ts','vitest.workspace.js','vitest.workspace.json'];
320
426
  const at=args.findIndex(arg=>arg==='--config'||arg==='-c'),inline=args.find(arg=>/^(?:--config|-c)=/.test(arg));
321
427
  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{}
428
+ // Vitest finds its configuration in the package directory or a parent.
429
+ const texts=[],folders=[];
430
+ for(let at=dir;;at=path.posix.dirname(at)){folders.push(path.join(checkout,at));if(at==='.')break;}
431
+ for(const name of [...names,...(given?[given]:[])])for(const folder of folders){
432
+ try{texts.push(fs.readFileSync(path.resolve(folder,name),'utf8'));}catch{}
325
433
  }
326
434
  try{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
327
435
  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';
436
+ return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:
437
+ dir==='.'?'coverage':`${dir}/coverage`;
329
438
  }
330
439
  // The caller's environment, when it would load code before the recorder or
331
440
  // the Node version is not one the recipe supports. Compared variables are
@@ -345,13 +454,17 @@ export function qualifyTests(root,selected){
345
454
  const issues=[];
346
455
  if(!['darwin','linux'].includes(process.platform))issues.push('the test recipe records runs on macOS and Linux only');
347
456
  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');
457
+ const dir=selected.cwd&&selected.cwd!=='.'?selected.cwd:'.';
458
+ if(dir!=='.'&&!selected.workspace)throw Object.assign(Error(),{code:'a test check in a directory needs its workspace'});
459
+ const project=testProject(root,selected.script,dir);
460
+ if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args]))
461
+ issues.push('test runner invocation changed; run redue init again');
351
462
  // Every checkout file but installed ones is a content input; the recorder
352
463
  // 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/**')||
464
+ if(dir!=='.'&&JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8')).name!==selected.workspace)
465
+ issues.push('the test check names another workspace package than its directory holds; run redue init again');
466
+ const expected=testCheckInputs(project),sorted=list=>JSON.stringify([...(list||[])].sort());
467
+ if(sorted(selected.inputs)!==sorted(expected.inputs)||
355
468
  JSON.stringify([...(selected.installedInputs||[])].sort())!==JSON.stringify([...testCheckInputs(project).installedInputs].sort()))
356
469
  issues.push('test inputs must be the whole checkout, with node_modules observed as installed files; run redue init again');
357
470
  }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.
@@ -0,0 +1,23 @@
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 micromatch 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 positive=patterns.filter(p=>!p.startsWith('!')),negative=patterns.filter(p=>p.startsWith('!')).map(p=>p.slice(1));
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')&&
19
+ micromatch.isMatch(dir,positive,{dot:true})&&!(negative.length&&micromatch.isMatch(dir,negative,{dot:true}));
20
+ }).sort();
21
+ if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
22
+ return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));
23
+ }