@redue/cli 0.1.0-alpha.16 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.17 — 2026-10-22
4
+
5
+ Workspaces and package managers beyond npm. In the census of 108 public
6
+ TypeScript repos, the ones `init` makes ready went from 5 to 8 for typecheck
7
+ and from 6 to 22 for tests.
8
+
9
+ - pnpm 9-12 for the pnpm TypeScript contract (it took only an exact pnpm 12
10
+ pin). The installed `.modules.yaml` names the version; a pin is optional,
11
+ with or without Corepack's `+sha512` hash (pins with a hash never matched
12
+ before). `.npmrc` settings that only change what gets installed are
13
+ accepted; settings that move or copy installed files, or run code during
14
+ install, are refused in either file: custom modules, virtual-store or
15
+ lockfile directories, the global virtual store, injected workspace
16
+ packages, separate lockfiles, pnpmfile hooks, `configDependencies`.
17
+ - The tests recipe takes single-package pnpm 9-12 and Yarn `node-modules`
18
+ installs, and gives each workspace package (npm or Yarn `workspaces`, pnpm
19
+ `packages`) whose test script runs Vitest directly its own `PACKAGE:test`
20
+ check, run in the package directory. Inputs stay the whole checkout; every
21
+ package's node_modules is an installed input. Jest and root test scripts in
22
+ workspaces stay recording-only. A package added later makes the
23
+ workspace's checks UNVERIFIED until `init` runs again.
24
+ - A recorded test run is UNVERIFIED when an installed file has another hard
25
+ link: a package store outside the checkout (pnpm's default on Linux, Yarn's
26
+ hardlinks modes) could change it unseen. pnpm on macOS clones files and is
27
+ not affected; on Linux, pnpm test checks qualify but are not reused unless
28
+ pnpm copies files.
29
+ - Observer: an installed link to a directory (pnpm links every package) is
30
+ observed by its link chain when the target is itself observed installed;
31
+ any other directory link still makes observation unavailable.
32
+ - Workspace patterns honour `!pattern` exclusions; before, a negated pattern
33
+ matched every directory.
34
+ - Fixed: a new workspace package manifest did not re-plan a test check, for
35
+ npm workspaces too.
36
+
37
+ Eval on alpha.17 code (CI 39828002753, 84 sessions, test tasks on twilio,
38
+ cheerio and SwitchHosts plus the pnpm project devops-daily): 0 critical
39
+ findings; 23 skipped test runs, each passing three plain reruns; nothing was
40
+ skipped on devops-daily, whose recorded runs were UNVERIFIED on the Linux
41
+ runner.
3
42
  ## 0.1.0-alpha.16 — 2026-10-22
4
43
 
5
44
  Changes to the Claude Code integration, and one fix for commands that meet
package/README.md CHANGED
@@ -37,7 +37,7 @@ it; relevant source edits make it stale. [Run the reproducible demo](docs/contri
37
37
 
38
38
  ## Install the alpha
39
39
 
40
- The opt-in pilot uses **0.1.0-alpha.16**. Follow the
40
+ The opt-in pilot uses **0.1.0-alpha.17**. Follow the
41
41
  [fixed-version pilot guide](docs/user/pilot.md) for supported projects, setup,
42
42
  updates and voluntary feedback. No external usage results are claimed yet.
43
43
 
package/bin/redue.mjs CHANGED
@@ -4,7 +4,7 @@ import {mark,traceBase,traceContext,traceEnvironment,traceId} from '../src/decis
4
4
  traceBase({...traceContext(),request:traceId(),work:'client'});
5
5
  mark('client.loaded');
6
6
  import {directCompiler,directQualification,directEnvironment,directInterpretation} from '../src/direct-typescript.mjs';
7
- import {testEnvironment,testRunnerEntry,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
7
+ import {testEnvironment,testRunnerAt,testsInterpretation,testsQualification} from '../src/test-direct.mjs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import {createHash} from 'node:crypto';
@@ -194,7 +194,7 @@ function which(name){
194
194
  if(found)return found;
195
195
  error(`cannot resolve executable ${name} on PATH`);
196
196
  }
197
- function token(value){
197
+ function token(value,cwd='.'){
198
198
  if(value==='@node')return process.execPath;
199
199
  if(value==='@project')return root;
200
200
  if(value==='@typescript-compiler:.'){
@@ -202,8 +202,8 @@ function token(value){
202
202
  }
203
203
  if(value.startsWith('@test-runner:')){
204
204
  // Qualification reports a missing runner; status and stop still work.
205
- try{return testRunnerEntry(root,value.slice('@test-runner:'.length));}
206
- catch{return path.join(root,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
205
+ try{return testRunnerAt(root,cwd,value.slice('@test-runner:'.length)).entry;}
206
+ catch{return path.join(root,cwd,'node_modules',value.slice('@test-runner:'.length),'<unavailable>');}
207
207
  }
208
208
  if(value==='@vstate/direct-typescript-probe')return path.join(productRoot,'src','direct-typescript-probe.mjs');
209
209
  if(value.startsWith('@typescript-bin:')){
@@ -226,10 +226,10 @@ function token(value){
226
226
  if(value.startsWith('@which:'))return which(value.slice(7));
227
227
  return value;
228
228
  }
229
- function argv(values,label){
229
+ function argv(values,label,cwd){
230
230
  if(!Array.isArray(values)||!values.length||values.some(v=>typeof v!=='string'||!v))
231
231
  error(`${label} must be nonempty exact argv`);
232
- return values.map(token);
232
+ return values.map(value=>token(value,cwd));
233
233
  }
234
234
  const categories=['source','generated','installedDependencies','environment','toolchain','runtime',
235
235
  'probeContinuity'];
@@ -260,7 +260,7 @@ const checks=publicConfig.checks.map(check=>{
260
260
  JSON.stringify(check.command)!==JSON.stringify(['@node','@typescript-compiler:.','--noEmit'])))
261
261
  error(`${check.name}: direct recipe requires its explicit local compiler command`);
262
262
  // The script's own arguments follow the runner; qualification compares them with the script.
263
- if(testsDirect&&(manager!=='npm'||!check.script||check.cwd&&check.cwd!=='.'||!Array.isArray(check.command)||
263
+ if(testsDirect&&(!['npm','pnpm','yarn'].includes(manager)||!check.script||check.cwd&&check.cwd!=='.'&&!check.workspace||!Array.isArray(check.command)||
264
264
  check.command[0]!=='@node'||!/^@test-runner:(?:vitest|jest)$/.test(check.command[1]||'')))
265
265
  error(`${check.name}: test recipe requires its explicit local test runner command`);
266
266
  if(npmAutomatic&&(manager!=='npm'||check.cwd&&check.cwd!=='.'))
@@ -297,7 +297,8 @@ const checks=publicConfig.checks.map(check=>{
297
297
  {variables:['NODE_OPTIONS','NODE_PATH','CI','HOME','NODE_ENV','BASH_ENV','ENV',...windowsContext],
298
298
  prefixes:['PNPM_','COREPACK_','npm_config_','DYLD_','TSGO_'],
299
299
  pathExecutables:['node'],executableIdentity:true}:{};
300
- return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`),
300
+ return {name:check.name,command:argv(scriptCommand||check.command,`${check.name}.command`,
301
+ typeof check.cwd==='string'&&check.cwd&&!path.isAbsolute(check.cwd)&&!check.cwd.split('/').includes('..')?check.cwd:'.'),
301
302
  cwd:check.cwd,inputs:check.inputs||[],generatedInputs:check.generatedInputs||[],
302
303
  installedInputs:check.installedInputs||(
303
304
  npmAutomatic||npmDirect||testsDirect?['node_modules/**']:[]),
@@ -1,4 +1,4 @@
1
- # Opt-in development pilot: alpha.16
1
+ # Opt-in development pilot: alpha.17
2
2
 
3
3
  Use one fixed build for several ordinary development sessions. The owner will
4
4
  invite two or three willing testers and approve their projects; nothing is
@@ -35,7 +35,7 @@ are **NOT RUN**. The recording shortcut is macOS/direct-recipe only.
35
35
  For a new default npm installation:
36
36
 
37
37
  ```sh
38
- npm install --global @redue/cli@0.1.0-alpha.16
38
+ npm install --global @redue/cli@0.1.0-alpha.17
39
39
  redue --version
40
40
  cd my-project
41
41
  redue --config redue.config.json init --recipe typescript-direct --check typecheck --dry-run
@@ -89,7 +89,7 @@ using it; keep the same npm prefix, config and any explicit state selection.
89
89
 
90
90
  ```sh
91
91
  redue --config redue.config.json stop
92
- npm install --global @redue/cli@0.1.0-alpha.16
92
+ npm install --global @redue/cli@0.1.0-alpha.17
93
93
  redue --config redue.config.json agent setup codex --dry-run
94
94
  redue --config redue.config.json agent setup codex --apply
95
95
  redue --config redue.config.json start
@@ -45,13 +45,26 @@ project whose test script runs Vitest 4/5 or Jest 29 directly, the opt-in
45
45
  In particular, test caches and build-generated inputs are not excluded to
46
46
  make evidence green.
47
47
 
48
- Pinned pnpm 12 projects are read from `packageManager`, `pnpm-workspace.yaml`,
49
- the lockfile, and the installed `node_modules/.modules.yaml` metadata. The
48
+ pnpm 9-12 projects are read from `packageManager`, `pnpm-workspace.yaml`,
49
+ `.npmrc`, the lockfile, and the installed `node_modules/.modules.yaml`
50
+ metadata. The installed metadata names the pnpm version; a `packageManager`
51
+ pin is optional, and when present (with or without Corepack's `+sha512` hash)
52
+ it must name the installed version. Settings that only change what gets
53
+ installed are accepted, because the installed files are what REDUE observes.
54
+ Settings that move or copy installed files, or run code during install, are
55
+ not: custom `modulesDir`, `virtualStoreDir` or `lockfileDir`, the global
56
+ virtual store, injected workspace packages, separate workspace lockfiles,
57
+ pnpmfile hooks and `configDependencies`. They are refused when either
58
+ `pnpm-workspace.yaml` or `.npmrc` sets them. The linker is read from the file
59
+ the installed major reads (`.npmrc` for pnpm 9, `pnpm-workspace.yaml` then
60
+ `.npmrc` for pnpm 10, `pnpm-workspace.yaml` for pnpm 11 and 12) and must match
61
+ the installed one. Only the project's files are read: settings from a user or
62
+ global rc file, or `npm_config_*` variables during install, are not. The
50
63
  supported local layouts are pnpm's standard isolated and hoisted
51
64
  `node_modules` with a project-local `.pnpm` virtual store. `init` can discover
52
65
  root and workspace scripts. For a standalone `tsc --noEmit` or `tsc -p ...`
53
66
  script in the narrow TypeScript contract, it records the exact installed
54
- compiler invocation directly. This avoids pnpm 12's task-run bookkeeping
67
+ compiler invocation directly. This avoids pnpm's task-run bookkeeping
55
68
  writes during `pnpm run`; it does not alter the project's script. Test, lint,
56
69
  build, and ambiguous TypeScript scripts still execute through `pnpm run` and
57
70
  start recording-only. The generated `@typescript-bin:WORKSPACE` token resolves
@@ -2,8 +2,24 @@
2
2
 
3
3
  `redue init --recipe tests-direct --check test` records a project's unit test
4
4
  run and reports later whether that result still applies to the files you have.
5
- It supports a single-package npm project whose test script runs Vitest 4 or 5,
6
- or Jest 29, directly. It runs on macOS and Linux with Node 22 or 24.
5
+ It supports a single-package project installed by npm, pnpm 9-12 (isolated or
6
+ hoisted `node_modules`) or Yarn with the `node-modules` linker, whose test
7
+ script runs Vitest 4 or 5, or Jest 29, directly. It runs on macOS and Linux with
8
+ Node 22 or 24. The project has one lockfile kind, matching any
9
+ `packageManager` declaration; Yarn needs that declaration. With pnpm, the
10
+ runner is the package `node_modules/RUNNER` links to, inside the checkout.
11
+ Jest also needs jest-util 29 at `node_modules/jest-util`, which pnpm does not
12
+ place there unless it hoists.
13
+
14
+ In a workspace (npm or Yarn `workspaces`, or `packages` in
15
+ `pnpm-workspace.yaml`), `init` gives each package whose test script runs
16
+ Vitest directly its own check, `PACKAGE:test`, run in the package's directory
17
+ with the Vitest that Node finds from there. Its inputs are still the whole
18
+ checkout, so an edit in any package makes it STALE. The root's and every
19
+ package's `node_modules` are observed as installed files, without Vite's
20
+ caches in them. Jest is not supported in workspace packages, and neither is a
21
+ test script at the workspace root. A package added later needs `init` again:
22
+ its manifest appearing makes the workspace's checks UNVERIFIED until then.
7
23
 
8
24
  ## Setup
9
25
 
@@ -21,8 +37,8 @@ runner invocation: no `&&`, quotes, globs or variable assignments. Watch mode,
21
37
  Jest's `--onlyChanged` are refused. Vitest needs `run`, `--run` or
22
38
  `--watch=false`. Other scripts stay recording-only.
23
39
 
24
- REDUE runs the local runner with Node directly. npm, its lifecycle scripts and
25
- `package.json` are not changed or executed.
40
+ REDUE runs the local runner with Node directly. The package manager, its
41
+ lifecycle scripts and `package.json` are not changed or executed.
26
42
 
27
43
  ## What REDUE claims
28
44
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redue/cli",
3
3
  "private": false,
4
- "version": "0.1.0-alpha.16",
4
+ "version": "0.1.0-alpha.17",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "tag": "alpha"
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
6
  import {runFactChange} from './typescript-list.mjs';
7
- import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testProject,testEnvironmentIssue,testGuardConfig,testRunGuard,testsInterpretation,testsQualification} from './test-direct.mjs';
7
+ import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
10
  import os from 'node:os';
@@ -273,14 +273,17 @@ async function execute() {
273
273
  ...command.slice(1)]:command;
274
274
  // A test run's processes record what the guard checks (test-recorder.cjs).
275
275
  const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
276
- let testRecord=null,launchEnv=process.env;
276
+ let testRecord=null,launchEnv=process.env,testFacts={};
277
277
  if(testRun){
278
+ // The package directory and installed node_modules, as the plan has them.
279
+ try{testFacts=testRunFacts(root,selected);}
280
+ catch(e){captureIssues.push(`test run facts unavailable (${e.code||e.message})`);}
278
281
  const environmentIssue=testEnvironmentIssue();
279
282
  if(environmentIssue)captureIssues.push(environmentIssue);
280
283
  testRecord=path.join(state,`test-record-${runId}`);
281
284
  fs.mkdirSync(testRecord,{mode:0o700});
282
285
  const guardFile=path.join(testRecord,'guard.json');
283
- fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root)),{mode:0o600});
286
+ fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root,process.env,testFacts)),{mode:0o600});
284
287
  launchEnv=recordedEnvironment(guardFile);
285
288
  }
286
289
  let runnerPid=null;
@@ -354,10 +357,12 @@ async function execute() {
354
357
  }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
355
358
  let guard=null;
356
359
  if(testRecord){
357
- let coverage=null;try{coverage=coverageReadable(root,testProject(root,selected.script));}catch{}
360
+ let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
358
361
  guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
359
362
  runner:runnerPid,environment:selected.environment||{},coverage});
360
363
  runFacts=guard.runFacts;
364
+ const shared=sharedInstalledFile(root,testFacts);
365
+ if(shared)guard.issues.push(`installed file ${shared} has another hard link, likely into a package store; a change through it is not observed`);
361
366
  fs.rmSync(testRecord,{recursive:true,force:true});
362
367
  }
363
368
  // The daemon reads the recording from owned state; control requests are small.
package/src/daemon.mjs CHANGED
@@ -271,7 +271,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
271
271
  'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
272
272
  ,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
273
273
  ,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
274
- ,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
274
+ ,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
275
275
  ,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
276
276
  ,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
277
277
  ,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
@@ -6,7 +6,7 @@ import {createHash} from 'node:crypto';
6
6
  import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
7
7
  import {qualifyTypeScript} from './typescript-qualification.mjs';
8
8
  import {directBuildInfo} from './direct-typescript.mjs';
9
- import {qualifyTests,testsQualification} from './test-direct.mjs';
9
+ import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
10
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
11
11
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
12
12
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
@@ -92,6 +92,13 @@ export function discoverDeclared(config) {
92
92
  ...(workspaceIssue?[workspaceIssue]:[])];
93
93
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
94
94
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
95
+ // A workspace test check is qualified against the current package list: a
96
+ // package manifest appearing or pnpm's package list changing re-plans.
97
+ if(selected.qualification===testsQualification&&selected.workspace)try{
98
+ for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
99
+ workspacePatterns.add(item);
100
+ if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
101
+ }catch{}
95
102
  for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
96
103
  if(!automatic)for(const category of ['source','generated','installedDependencies',
97
104
  'environment','toolchain','runtime'])if(!selected.coverage?.[category])
package/src/index.mjs CHANGED
@@ -42,24 +42,33 @@ const pathPrefixes = rel => {
42
42
  // tick at the same size; such files are tracked in `racy`, and the daemon waits
43
43
  // the window out and records them again before it writes a run checkpoint.
44
44
  const identity=st=>identityFields.map(key=>String(st[key])).join(':');
45
+ // An installed link to a directory (pnpm links every package this way) is
46
+ // observed by its link chain alone, when `observedDir` says the directory it
47
+ // resolves to is itself observed installed: its own entries carry its contents.
45
48
  function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
46
- {bigint:true})) {
49
+ {bigint:true}),observedDir=null) {
47
50
  const file=path.join(root, rel);
48
51
  let value,references=[],bytes=installed?0:Number(before.size),racy=0;
49
52
  if(before.isSymbolicLink()){
50
53
  const resolution=resolveLinks(file,root,[],true);
51
54
  if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
52
55
  const target=fs.statSync(resolution.physical,{bigint:true});
53
- if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
54
- if(installed)racy=racyUntil(target);else bytes=Number(target.size);
55
- value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
56
- target.mode+':'+hashRegular(resolution.physical).hash);
56
+ const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
57
+ const directory=target.isDirectory()&&installed&&observedDir?.(physical);
58
+ if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel}`);
59
+ if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
60
+ else{
61
+ if(installed)racy=racyUntil(target);else bytes=Number(target.size);
62
+ value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
63
+ target.mode+':'+hashRegular(resolution.physical).hash);
64
+ }
57
65
  references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
58
66
  .split(path.sep).join('/')),path.relative(root,resolution.physical)
59
67
  .split(path.sep).join('/')])];
60
- const targetAfter=fs.statSync(resolution.physical,{bigint:true});
61
- for(const key of identityFields)
62
- if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
68
+ // A directory's own timestamps follow its entries, which are observed separately.
69
+ if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
70
+ for(const key of identityFields)
71
+ if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
63
72
  const resolutionAfter=resolveLinks(file,root,[],true);
64
73
  if(resolutionAfter.physical!==resolution.physical||
65
74
  JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
@@ -181,7 +190,14 @@ export class InputIndex {
181
190
  catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
182
191
  if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
183
192
  const installed=this.installedPath(rel);
184
- try{const started=performance.now();inspection=fileHash(this.root,rel,installed,stat);
193
+ try{const started=performance.now();
194
+ // A linked directory counts as observed only strictly inside an
195
+ // installed root, and only if every check matching the link also
196
+ // observes the directory's own files (its package.json stands for them).
197
+ const observedDir=installed?physical=>!this.includeInstalled&&
198
+ [...this.installedRootSet].some(root=>physical.startsWith(root+'/'))&&
199
+ matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
200
+ inspection=fileHash(this.root,rel,installed,stat,observedDir);
185
201
  fresh=inspection.hash;
186
202
  this.profile.hashingMs+=performance.now()-started;
187
203
  this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
@@ -302,9 +318,10 @@ export class InputIndex {
302
318
  const full=path.join(this.root,rel);
303
319
  let subtree=false;
304
320
  try {
305
- if(fs.lstatSync(full).isSymbolicLink()&&fs.statSync(full).isDirectory()){
306
- this.unavailable=`directory symlink input unsupported: ${rel}`;return;}
307
- if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
321
+ // A link is an entry of its own: record() accepts an observed installed
322
+ // directory link and refuses any other.
323
+ if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
324
+ else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
308
325
  else this.record(rel);
309
326
  } catch(e) {
310
327
  if(e.code==='ENOENT') {
@@ -84,6 +84,10 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
84
84
  if(!resolved.physical){
85
85
  if(resolved.reason==='resolved target missing'&&!resolved.links.length){
86
86
  internal.add(pattern);continue;}
87
+ // An exclusion of a path that does not exist yet, through links (a runner
88
+ // cache inside a linked package): exclude it where the links lead.
89
+ if(negative&&resolved.reason==='resolved target missing'&&resolved.prospective&&within(resolved.prospective,root)){
90
+ internal.add('!'+posix(path.relative(root,resolved.prospective))+(suffix?'/'+suffix:''));continue;}
87
91
  result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
88
92
  }
89
93
  const boundary=within(resolved.physical,root)?root:
@@ -3,11 +3,11 @@ import {testProject,testRecipeScript,testCheckInputs,testsQualification} from '.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import {spawnSync} from 'node:child_process';
6
- import micromatch from './glob.mjs';
7
6
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
8
7
  import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
9
8
  import {realObservedPath} from './path-identity.mjs';
10
9
  import {findExecutable} from './executable-lookup.mjs';
10
+ import {workspaceManifests} from './workspace-manifests.mjs';
11
11
 
12
12
  const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
13
13
  const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
@@ -147,20 +147,6 @@ function selectScript(kind,names,scripts) {
147
147
  return candidates.length===1?{script:candidates[0]}:
148
148
  candidates.length>1?{ambiguous:candidates}:{};
149
149
  }
150
- function workspaceManifests(root,patterns) {
151
- const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
152
- '-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
153
- maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
154
- env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
155
- if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
156
- const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
157
- const dir=path.posix.dirname(file);
158
- return dir!=='.'&&!dir.split('/').includes('node_modules')&&
159
- micromatch.isMatch(dir,patterns,{dot:true});
160
- }).sort();
161
- if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
162
- return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
163
- }
164
150
  // Workspace packages as init finds them, with their scripts; [] without workspaces.
165
151
  export function workspacePackages(root){
166
152
  root=realObservedPath(root);
@@ -209,7 +195,8 @@ export function discoverNodeProject(root,{recipe=null}={}) {
209
195
  }
210
196
  if(recipe==='tests-direct'&&kind==='test'){
211
197
  try{
212
- if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('test recipe requires a supported single-package npm checkout');
198
+ if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
199
+ throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
213
200
  const chosen=testRecipeScript(scripts);
214
201
  if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
215
202
  if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
@@ -230,7 +217,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
230
217
  check.inputs=['package.json','pnpm-lock.yaml',
231
218
  ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
232
219
  'tsconfig.json','src/**'];
233
- classification={level:'ready',reason:'pinned pnpm TypeScript inputs and installation; checked again before reuse',
220
+ classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
234
221
  qualification:'pnpm-tsc-v1'};}
235
222
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
236
223
  }
@@ -284,9 +271,17 @@ export function discoverNodeProject(root,{recipe=null}={}) {
284
271
  ...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
285
272
  check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
286
273
  `${dir}/src/**`];
287
- level='ready';reason='pinned pnpm workspace TypeScript inputs; checked again before reuse';
274
+ level='ready';reason='pnpm workspace TypeScript inputs; checked again before reuse';
288
275
  }catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
289
276
  }
277
+ if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
278
+ try{const project=testProject(root,selected.script,dir);
279
+ Object.assign(check,{workspace:name,script:selected.script,cwd:dir,
280
+ command:['@node',`@test-runner:${project.runner}`,...project.args],...testCheckInputs(project),
281
+ qualification:testsQualification});
282
+ level='ready';reason=`explicit local ${project.runner} ${project.version} run of ${name}'s script ${JSON.stringify(selected.script)}; the whole checkout is its input; the package manager and lifecycle scripts are not executed`;
283
+ }catch(e){reason=`Test recipe unavailable (${e.code||e.message}); original script retained`;}
284
+ }
290
285
  checks.push({config:check,level,reason});
291
286
  }
292
287
  }
@@ -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
+ }