@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 +39 -0
- package/README.md +1 -1
- package/bin/redue.mjs +9 -8
- package/docs/user/pilot.md +3 -3
- package/docs/user/supported-projects.md +16 -3
- package/docs/user/tests-direct.md +20 -4
- package/package.json +1 -1
- package/src/cli.mjs +9 -4
- package/src/daemon.mjs +1 -1
- package/src/declared-plan.mjs +8 -1
- package/src/index.mjs +29 -12
- package/src/installed-inputs.mjs +4 -0
- package/src/node-onboarding.mjs +13 -18
- package/src/pnpm-inputs.mjs +62 -31
- package/src/test-direct.mjs +145 -32
- package/src/test-recorder.cjs +3 -2
- package/src/workspace-manifests.mjs +23 -0
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.
|
|
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,
|
|
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
|
|
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&&(
|
|
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/**']:[]),
|
package/docs/user/pilot.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Opt-in development pilot: alpha.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
49
|
-
the lockfile, and the installed `node_modules/.modules.yaml`
|
|
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
|
|
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
|
|
6
|
-
|
|
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.
|
|
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
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,
|
|
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,
|
|
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
|
|
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'
|
package/src/declared-plan.mjs
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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();
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
if(fs.
|
|
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') {
|
package/src/installed-inputs.mjs
CHANGED
|
@@ -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:
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -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
|
|
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:'
|
|
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='
|
|
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
|
}
|
package/src/pnpm-inputs.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
37
|
-
const patterns=safePatterns(
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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(
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
//
|
|
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']:[]),
|
package/src/test-direct.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if(typeof
|
|
286
|
-
const {runner,args}=testScript(
|
|
287
|
-
|
|
288
|
-
const
|
|
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
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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:[
|
|
306
|
-
'
|
|
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(
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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:
|
|
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
|
|
349
|
-
if(
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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');}
|
package/src/test-recorder.cjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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&µmatch.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
|
+
}
|