@redue/cli 0.1.0-alpha.15 → 0.1.0-alpha.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +1 -1
- package/bin/redue.mjs +17 -10
- package/docs/user/agents.md +46 -11
- 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/agent-integration.mjs +43 -28
- package/src/claude-hook.mjs +229 -40
- 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 +21 -17
- 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/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
|
+
}
|