@redue/cli 0.1.0-alpha.16 → 0.1.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/README.md +1 -1
- package/bin/redue.mjs +25 -14
- package/docs/user/agents.md +11 -0
- package/docs/user/configuration.md +4 -0
- package/docs/user/direct-typescript.md +62 -5
- package/docs/user/pilot.md +6 -5
- package/docs/user/supported-projects.md +29 -7
- package/docs/user/tests-direct.md +28 -4
- package/package.json +2 -2
- package/src/agent-integration.mjs +2 -2
- package/src/claude-hook.mjs +100 -27
- package/src/cli.mjs +9 -4
- package/src/daemon.mjs +21 -7
- package/src/declared-plan.mjs +11 -4
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/index.mjs +29 -12
- package/src/installed-inputs.mjs +4 -0
- package/src/node-onboarding.mjs +90 -47
- package/src/npm-integrity.mjs +124 -0
- package/src/npmrc.mjs +16 -0
- package/src/plan-worker.mjs +4 -1
- package/src/pnpm-inputs.mjs +111 -52
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/test-direct.mjs +198 -36
- package/src/test-recorder.cjs +3 -2
- package/src/typescript-input-facts.mjs +7 -7
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +2 -2
- package/src/typescript-qualification.mjs +8 -9
- package/src/workspace-manifests.mjs +22 -0
package/src/test-direct.mjs
CHANGED
|
@@ -10,9 +10,13 @@ import path from 'node:path';
|
|
|
10
10
|
import {createHash} from 'node:crypto';
|
|
11
11
|
import {fileURLToPath} from 'node:url';
|
|
12
12
|
import {spawnSync} from 'node:child_process';
|
|
13
|
-
import
|
|
13
|
+
import {patternMatcher} from './glob.mjs';
|
|
14
14
|
import {runFact} from './direct-run-certificate.mjs';
|
|
15
15
|
import {queryAnswer} from './typescript-list.mjs';
|
|
16
|
+
import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
|
|
17
|
+
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
18
|
+
import {atomicJson} from './state-store.mjs';
|
|
19
|
+
import {racyUntil} from './path-identity.mjs';
|
|
16
20
|
|
|
17
21
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
18
22
|
export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
|
|
@@ -39,10 +43,10 @@ const issueLimit=20,factLimit=10000;
|
|
|
39
43
|
|
|
40
44
|
// The guard file the recorder reads: where to write, what counts as the
|
|
41
45
|
// checkout and as temporary space, and digests of the caller's environment.
|
|
42
|
-
export function testGuardConfig(dir,root,env=process.env) {
|
|
46
|
+
export function testGuardConfig(dir,root,env=process.env,project={}) {
|
|
43
47
|
const tmp=[...new Set([path.resolve(os.tmpdir()),fs.realpathSync(os.tmpdir())])];
|
|
44
48
|
const real=dir=>{try{return fs.realpathSync(dir);}catch{return dir;}};
|
|
45
|
-
return {schema:1,dir,root,tmp,runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
|
|
49
|
+
return {schema:1,dir,root,tmp,installed:modulesDirs(project).map(at=>path.join(root,at)),runnerData:[...new Set(runnerDataDirectories(env).flatMap(dir=>[path.resolve(dir),real(dir)]))],
|
|
46
50
|
env:Object.fromEntries(Object.entries(env).filter(([,value])=>typeof value==='string').map(([key,value])=>[key,digest(value)]))};
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -63,9 +67,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
63
67
|
const compared=name=>(environment.variables||[]).includes(name)||
|
|
64
68
|
(environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
65
69
|
const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
|
|
66
|
-
const
|
|
67
|
-
const negative=sourcePatterns.filter(p=>p.startsWith('!')).map(p=>micromatch.matcher(p.slice(1),{dot:true}));
|
|
68
|
-
const included=rel=>positive.some(fn=>fn(rel))&&!negative.some(fn=>fn(rel));
|
|
70
|
+
const included=patternMatcher(sourcePatterns);
|
|
69
71
|
const observed=rel=>Object.hasOwn(files||{},rel);
|
|
70
72
|
const headers=new Map(),records=[];
|
|
71
73
|
let names=[];
|
|
@@ -262,48 +264,199 @@ export function testRecipeScript(scripts){
|
|
|
262
264
|
return near?{issue:issue(near)}:{};
|
|
263
265
|
}
|
|
264
266
|
const majorOf=version=>Number(String(version).split('.')[0]);
|
|
265
|
-
// The runner's executable inside the checkout's node_modules.
|
|
267
|
+
// The runner's executable inside the checkout's node_modules. pnpm links
|
|
268
|
+
// node_modules/RUNNER into node_modules/.pnpm; the files stay in node_modules.
|
|
266
269
|
export function testRunnerEntry(root,runner){
|
|
267
270
|
if(!runners[runner])fail('test-runner-unsupported');
|
|
268
271
|
const dir=path.join(root,'node_modules',runner);
|
|
269
272
|
let pkg,real;
|
|
270
273
|
try{real=fs.realpathSync(dir);pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}
|
|
271
274
|
catch{fail('test-runner-missing');}
|
|
272
|
-
|
|
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
|
+
// pnpm's content-addressed store, as the install recorded it in .modules.yaml:
|
|
340
|
+
// files/<first 2 hex>/<rest of the content's sha512 hex>, "-exec" for
|
|
341
|
+
// executables. pnpm writes new store files and never changes one in place.
|
|
342
|
+
function pnpmStoreFiles(root){
|
|
343
|
+
try{const {storeDir}=yaml(path.join(root,'node_modules','.modules.yaml'));
|
|
344
|
+
return typeof storeDir==='string'&&path.isAbsolute(storeDir)?path.join(storeDir,'files'):null;}
|
|
345
|
+
catch{return null;}
|
|
346
|
+
}
|
|
347
|
+
const identity=st=>`${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
|
|
348
|
+
const sameFile=(file,st)=>{
|
|
349
|
+
try{const other=fs.statSync(file,{bigint:true});return other.dev===st.dev&&other.ino===st.ino;}
|
|
350
|
+
catch{return false;}};
|
|
351
|
+
// The file's store path relative to `files`, or null when the store does not
|
|
352
|
+
// hold this file at its content's address.
|
|
353
|
+
function storePath(files,file,st){
|
|
354
|
+
const hash=createHash('sha512'),buffer=Buffer.alloc(1<<20),fd=fs.openSync(file,'r');
|
|
355
|
+
try{for(let n;(n=fs.readSync(fd,buffer,0,buffer.length,null))>0;)hash.update(buffer.subarray(0,n));}
|
|
356
|
+
finally{fs.closeSync(fd);}
|
|
357
|
+
const hex=hash.digest('hex'),at=path.join(hex.slice(0,2),hex.slice(2));
|
|
358
|
+
return [at,at+'-exec'].find(candidate=>sameFile(path.join(files,candidate),st))??null;
|
|
359
|
+
}
|
|
360
|
+
// The first installed file with more than one link, as {file, reason}, or
|
|
361
|
+
// null. A package store outside the checkout (pnpm's default on Linux, Yarn's
|
|
362
|
+
// hardlinks modes) can change such a file with no event here. A link into
|
|
363
|
+
// pnpm's own store is accepted: the store's file is named by its content.
|
|
364
|
+
// Checked at every recorded run: a later install that adds links replaces the
|
|
365
|
+
// files, which is a change REDUE sees. `cache` (in owned state) keeps each
|
|
366
|
+
// file found in the store with its identity and store path, so an unchanged
|
|
367
|
+
// file is not hashed again; its store link is still checked.
|
|
368
|
+
export function sharedInstalledFile(root,project={},cache=null){
|
|
369
|
+
const stack=modulesDirs(project).map(dir=>path.join(root,dir));
|
|
370
|
+
const files=pnpmStoreFiles(root),now=Date.now();
|
|
371
|
+
let known={},changed=false;
|
|
372
|
+
if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
|
|
373
|
+
if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
|
|
374
|
+
const found={};
|
|
375
|
+
const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
|
|
376
|
+
while(stack.length){
|
|
377
|
+
const dir=stack.pop();let entries;
|
|
378
|
+
try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
|
|
379
|
+
for(const entry of entries){
|
|
380
|
+
const file=path.join(dir,entry.name);
|
|
381
|
+
if(entry.isDirectory()){stack.push(file);continue;}
|
|
382
|
+
if(!entry.isFile())continue;
|
|
383
|
+
let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
|
|
384
|
+
if(st.nlink<=1n)continue;
|
|
385
|
+
if(!files)return shared(file,'has another hard link, and the install records no pnpm store');
|
|
386
|
+
const key=`${st.dev}:${st.ino}`,cached=known[key];
|
|
387
|
+
let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
|
|
388
|
+
sameFile(path.join(files,cached[1]),st)?cached[1]:null;
|
|
389
|
+
if(!at){
|
|
390
|
+
try{at=storePath(files,file,st);}
|
|
391
|
+
catch(e){return shared(file,`could not be checked against pnpm's store (${e.code||e.message})`);}
|
|
392
|
+
if(!at)return shared(file,"has another hard link outside pnpm's store");
|
|
393
|
+
changed=true;
|
|
394
|
+
}
|
|
395
|
+
// A file changed within the racy window could change again unseen in
|
|
396
|
+
// the same timestamp tick; it is hashed again next time.
|
|
397
|
+
if(racyUntil(st)<=now)found[key]=[identity(st),at];
|
|
398
|
+
else changed=true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
|
|
402
|
+
try{atomicJson(cache,{files,entries:found});}catch{}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
// The runner a package's script reaches: Node's lookup from the package
|
|
406
|
+
// directory up to the root, skipping candidates that are not the runner. Its
|
|
407
|
+
// files must be in an observed node_modules (`dirs`: workspace packages).
|
|
408
|
+
// `logical` is where the lookup found it, before links, relative to the root.
|
|
409
|
+
export function testRunnerAt(root,dir,runner,dirs=[]){
|
|
410
|
+
if(dir==='.')return {entry:testRunnerEntry(root,runner),logical:`node_modules/${runner}`};
|
|
411
|
+
if(!runners[runner])fail('test-runner-unsupported');
|
|
412
|
+
const rootReal=fs.realpathSync(root),observed=modulesDirs({dirs}).map(at=>path.join(rootReal,at)+path.sep);
|
|
413
|
+
for(let at=dir;;at=path.posix.dirname(at)){
|
|
414
|
+
const logical=at==='.'?`node_modules/${runner}`:`${at}/node_modules/${runner}`;
|
|
415
|
+
let real,pkg;
|
|
416
|
+
try{real=fs.realpathSync(path.join(root,logical));pkg=JSON.parse(fs.readFileSync(path.join(real,'package.json'),'utf8'));}catch{}
|
|
417
|
+
if(pkg?.name===runner){
|
|
418
|
+
if(!observed.some(prefix=>real.startsWith(prefix)))fail('test-runner-layout-unsupported');
|
|
419
|
+
const bin=typeof pkg.bin==='string'?pkg.bin:pkg.bin?.[runner];
|
|
420
|
+
const entry=typeof bin==='string'?path.resolve(real,bin):null;
|
|
421
|
+
if(!entry||!entry.startsWith(real+path.sep)||!fs.existsSync(entry))fail('test-runner-layout-unsupported');
|
|
422
|
+
return {entry,logical};
|
|
423
|
+
}
|
|
424
|
+
if(at==='.')fail('test-runner-missing');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// The project facts the recipe needs, or an error code. `dir` is a workspace
|
|
428
|
+
// package's directory; there the recipe supports Vitest only.
|
|
429
|
+
export function testProject(root,script,dir='.'){
|
|
280
430
|
const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if(typeof
|
|
286
|
-
const {runner,args}=testScript(
|
|
287
|
-
|
|
288
|
-
const
|
|
431
|
+
testPackageManager(root,pkg,{workspaces:dir!=='.'});
|
|
432
|
+
const dirs=dir==='.'?[]:testWorkspaceDirs(root,pkg);
|
|
433
|
+
if(dir!=='.'&&!dirs.includes(dir))fail('test-workspace-unsupported');
|
|
434
|
+
const own=dir==='.'?pkg:JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8'));
|
|
435
|
+
if(typeof own.scripts?.[script]!=='string'||own.scripts['pre'+script]||own.scripts['post'+script])fail('test-script-changed');
|
|
436
|
+
const {runner,args}=testScript(own.scripts[script]);
|
|
437
|
+
if(dir!=='.'&&runner!=='vitest')fail('test-runner-unsupported');
|
|
438
|
+
const {entry,logical}=testRunnerAt(root,dir,runner,dirs);
|
|
439
|
+
const version=JSON.parse(fs.readFileSync(path.join(root,logical,'package.json'),'utf8')).version;
|
|
289
440
|
if(!runners[runner].majors.includes(majorOf(version)))fail('test-runner-version-unsupported');
|
|
290
441
|
if(runner==='jest'){
|
|
291
442
|
let util=null;try{util=JSON.parse(fs.readFileSync(path.join(root,'node_modules/jest-util/package.json'),'utf8')).version;}catch{}
|
|
292
443
|
if(majorOf(util)!==29||!fs.existsSync(path.join(root,'node_modules/jest-util/build/createProcessObject.js')))
|
|
293
444
|
fail('test-runner-layout-unsupported');
|
|
294
445
|
}
|
|
295
|
-
return {runner,version,args,entry};
|
|
446
|
+
return {runner,version,args,entry,dir,dirs,logical};
|
|
296
447
|
}
|
|
297
448
|
// Inputs: the whole checkout except documentation and the coverage report a
|
|
298
449
|
// --coverage run writes. Git-ignored files the run creates are handled per
|
|
299
|
-
// run (cli.mjs). Installed files
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
450
|
+
// run (cli.mjs). Installed files, in the root's node_modules and every
|
|
451
|
+
// workspace package's, are observed by identity; Vite's caches inside them
|
|
452
|
+
// are written by every Vitest run.
|
|
453
|
+
export function testCheckInputs({runner,args,dir='.',dirs=[],logical=`node_modules/${runner}`}){
|
|
454
|
+
const modules=modulesDirs({dirs});
|
|
455
|
+
return {inputs:['**',...modules.map(at=>`!${at}/**`),'!.git/**','!**/*.md','!**/*.mdx',
|
|
456
|
+
...(args.includes('--coverage')?[`!${dir==='.'?'':dir+'/'}coverage/**`]:[])],
|
|
304
457
|
// Vitest's typecheck mode keeps tsc's build info in its own dist directory.
|
|
305
|
-
installedInputs:[
|
|
306
|
-
'
|
|
458
|
+
installedInputs:modules.flatMap(at=>[`${at}/**`,...(runner==='vitest'?[`!${at}/.vite/**`,`!${at}/.vite-temp/**`,`!${at}/.vitest/**`]:[])])
|
|
459
|
+
.concat(runner==='vitest'?[`!${logical}/dist/tsconfig.tmp.tsbuildinfo`]:[])};
|
|
307
460
|
}
|
|
308
461
|
// The coverage directory a --coverage run may read back: Jest reads every
|
|
309
462
|
// .js file under its roots, an earlier report's included, to map dependencies,
|
|
@@ -311,21 +464,26 @@ export function testCheckInputs({runner,args}){
|
|
|
311
464
|
// when no configuration names thresholds (which make the report decide the
|
|
312
465
|
// result) or another report directory. Configuration can be code, so this
|
|
313
466
|
// reads its text.
|
|
314
|
-
export function coverageReadable(
|
|
467
|
+
export function coverageReadable(checkout,{runner,args,dir='.'}){
|
|
315
468
|
if(!args.includes('--coverage'))return null;
|
|
469
|
+
// A workspace package's configuration and report live in its directory.
|
|
470
|
+
const root=path.join(checkout,dir);
|
|
316
471
|
const names=runner==='jest'?['jest.config.js','jest.config.ts','jest.config.mjs','jest.config.cjs','jest.config.json']:
|
|
317
472
|
['vitest.config.ts','vitest.config.mts','vitest.config.cts','vitest.config.js','vitest.config.mjs','vitest.config.cjs',
|
|
318
473
|
'vite.config.ts','vite.config.mts','vite.config.cts','vite.config.js','vite.config.mjs','vite.config.cjs',
|
|
319
474
|
'vitest.workspace.ts','vitest.workspace.js','vitest.workspace.json'];
|
|
320
475
|
const at=args.findIndex(arg=>arg==='--config'||arg==='-c'),inline=args.find(arg=>/^(?:--config|-c)=/.test(arg));
|
|
321
476
|
const given=at>=0?args[at+1]:inline?.split(/=(.*)/s)[1];
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
477
|
+
// Vitest finds its configuration in the package directory or a parent.
|
|
478
|
+
const texts=[],folders=[];
|
|
479
|
+
for(let at=dir;;at=path.posix.dirname(at)){folders.push(path.join(checkout,at));if(at==='.')break;}
|
|
480
|
+
for(const name of [...names,...(given?[given]:[])])for(const folder of folders){
|
|
481
|
+
try{texts.push(fs.readFileSync(path.resolve(folder,name),'utf8'));}catch{}
|
|
325
482
|
}
|
|
326
483
|
try{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
|
327
484
|
if(runner==='jest'&&pkg.jest)texts.push(JSON.stringify(pkg.jest));}catch{return null;}
|
|
328
|
-
return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:
|
|
485
|
+
return texts.some(text=>/coverageThreshold|thresholds|coverageDirectory|reportsDirectory/.test(text))?null:
|
|
486
|
+
dir==='.'?'coverage':`${dir}/coverage`;
|
|
329
487
|
}
|
|
330
488
|
// The caller's environment, when it would load code before the recorder or
|
|
331
489
|
// the Node version is not one the recipe supports. Compared variables are
|
|
@@ -345,13 +503,17 @@ export function qualifyTests(root,selected){
|
|
|
345
503
|
const issues=[];
|
|
346
504
|
if(!['darwin','linux'].includes(process.platform))issues.push('the test recipe records runs on macOS and Linux only');
|
|
347
505
|
try{
|
|
348
|
-
const
|
|
349
|
-
if(
|
|
350
|
-
|
|
506
|
+
const dir=selected.cwd&&selected.cwd!=='.'?selected.cwd:'.';
|
|
507
|
+
if(dir!=='.'&&!selected.workspace)throw Object.assign(Error(),{code:'a test check in a directory needs its workspace'});
|
|
508
|
+
const project=testProject(root,selected.script,dir);
|
|
509
|
+
if(JSON.stringify(selected.command)!==JSON.stringify([process.execPath,project.entry,...project.args]))
|
|
510
|
+
issues.push('test runner invocation changed; run redue init again');
|
|
351
511
|
// Every checkout file but installed ones is a content input; the recorder
|
|
352
512
|
// skips node_modules because it is observed as installed files.
|
|
353
|
-
|
|
354
|
-
|
|
513
|
+
if(dir!=='.'&&JSON.parse(fs.readFileSync(path.join(root,dir,'package.json'),'utf8')).name!==selected.workspace)
|
|
514
|
+
issues.push('the test check names another workspace package than its directory holds; run redue init again');
|
|
515
|
+
const expected=testCheckInputs(project),sorted=list=>JSON.stringify([...(list||[])].sort());
|
|
516
|
+
if(sorted(selected.inputs)!==sorted(expected.inputs)||
|
|
355
517
|
JSON.stringify([...(selected.installedInputs||[])].sort())!==JSON.stringify([...testCheckInputs(project).installedInputs].sort()))
|
|
356
518
|
issues.push('test inputs must be the whole checkout, with node_modules observed as installed files; run redue init again');
|
|
357
519
|
}catch(e){issues.push(e.code||'test runner prerequisites unavailable');}
|
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.
|
|
@@ -9,7 +9,7 @@ import {timing} from './decision-profile.mjs';
|
|
|
9
9
|
const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
|
|
10
10
|
const digestFile=file=>hash(fs.readFileSync(file));
|
|
11
11
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
12
|
-
export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
12
|
+
export function typeScriptInputFacts(root,tsRoot,tsc,config='tsconfig.json'){
|
|
13
13
|
const configReads=new Set(),visited=new Set(),referenced=[];
|
|
14
14
|
let buildInfo=null;
|
|
15
15
|
let ts;try{ts=require(tsRoot);}catch{}
|
|
@@ -37,15 +37,15 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
37
37
|
parseConfig(reference.path.endsWith('.json')?reference.path:
|
|
38
38
|
path.join(reference.path,'tsconfig.json'));
|
|
39
39
|
};
|
|
40
|
-
parseConfig(path.join(root,
|
|
40
|
+
parseConfig(path.join(root,config),true);
|
|
41
41
|
}else{
|
|
42
42
|
// TypeScript 7's native compiler does not expose the previous config API.
|
|
43
43
|
// Its CLI reports the effective config; restrict this path to one direct
|
|
44
44
|
// config rather than guessing about extends/reference provenance.
|
|
45
|
-
const
|
|
45
|
+
const file=path.join(root,config),raw=fs.readFileSync(file,'utf8');
|
|
46
46
|
if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
|
|
47
47
|
fail('typescript-config-graph-unsupported');
|
|
48
|
-
const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
|
|
48
|
+
const shown=spawnSync(process.execPath,[tsc,'--showConfig','-p',config],{cwd:root,
|
|
49
49
|
env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
|
|
50
50
|
stdio:['ignore','pipe','pipe']});
|
|
51
51
|
if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
|
|
@@ -53,13 +53,13 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
53
53
|
if(effective.compilerOptions?.plugins?.length||
|
|
54
54
|
effective.compilerOptions?.incremental||effective.compilerOptions?.composite)
|
|
55
55
|
fail('typescript-config-graph-unsupported');
|
|
56
|
-
configReads.add(
|
|
56
|
+
configReads.add(file);
|
|
57
57
|
}
|
|
58
58
|
for(const file of configReads)if(!within(realObservedPath(file),root))
|
|
59
59
|
fail('typescript-config-outside-repository');
|
|
60
60
|
const listingStarted=performance.now();
|
|
61
|
-
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
|
|
62
|
-
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly'],
|
|
61
|
+
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root,config):null;
|
|
62
|
+
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly','-p',config],
|
|
63
63
|
{cwd:root,env:process.env,encoding:'utf8',timeout:probeTimeLeft(process.platform==='win32'?10000:4000),
|
|
64
64
|
maxBuffer:16*1024*1024,
|
|
65
65
|
stdio:['ignore','pipe','pipe']});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The package-script forms of a standalone TypeScript check: one `tsc` run, or
|
|
2
|
+
// several joined by `&&`, each with these arguments in any order:
|
|
3
|
+
// --noEmit, -p/--project PATH, --skipLibCheck, --pretty [true|false].
|
|
4
|
+
// Anything else (tsc -b, --watch, other options, other commands) is not one.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
// No shell metacharacters; a leading @ would read as a REDUE token.
|
|
9
|
+
const word=/^[A-Za-z0-9_./+-][A-Za-z0-9_./@+-]*$/;
|
|
10
|
+
// More runs than this is not a script REDUE splits into checks.
|
|
11
|
+
const maxRuns=16;
|
|
12
|
+
function parseRun(words){
|
|
13
|
+
if(words[0]!=='tsc'||!words.every(value=>word.test(value)))return null;
|
|
14
|
+
let project=null,noEmit=false;
|
|
15
|
+
for(let i=1;i<words.length;i++){
|
|
16
|
+
const value=words[i];
|
|
17
|
+
if(value==='-p'||value==='--project'){
|
|
18
|
+
if(project!==null||!words[i+1]||words[i+1].startsWith('-'))return null;
|
|
19
|
+
project=words[++i];
|
|
20
|
+
}else if(value==='--noEmit')noEmit=true;
|
|
21
|
+
else if(value==='--pretty'){if(['true','false'].includes(words[i+1]))i++;}
|
|
22
|
+
else if(value!=='--skipLibCheck')return null;
|
|
23
|
+
}
|
|
24
|
+
return {args:words.slice(1),project,noEmit};
|
|
25
|
+
}
|
|
26
|
+
// The runs of a script, as [{args, project, noEmit}], or null.
|
|
27
|
+
export function tscRuns(text){
|
|
28
|
+
if(typeof text!=='string'||!text.trim())return null;
|
|
29
|
+
const parts=text.trim().split(/[ \t]+&&[ \t]+/);
|
|
30
|
+
if(parts.length>maxRuns)return null;
|
|
31
|
+
const runs=parts.map(part=>parseRun(part.split(/[ \t]+/)));
|
|
32
|
+
return runs.every(Boolean)?runs:null;
|
|
33
|
+
}
|
|
34
|
+
// The run a check makes: its `script` (null for a check init proposed without
|
|
35
|
+
// one), the tsc arguments after the compiler in its command, and `count`, the
|
|
36
|
+
// number of configured checks for that script (more than one for a chain).
|
|
37
|
+
// Returns {run} with the parsed run, or {issue} when the arguments are outside
|
|
38
|
+
// the forms, or the script no longer has exactly `count` runs, one of them
|
|
39
|
+
// with exactly these arguments.
|
|
40
|
+
export function tscRun(scripts,{script=null,args,count=1}){
|
|
41
|
+
if(!Array.isArray(args)||!args.every(value=>typeof value==='string'))return {issue:'TypeScript arguments are invalid'};
|
|
42
|
+
const own=parseRun(['tsc',...args]);
|
|
43
|
+
if(!own)return {issue:'TypeScript arguments are outside the supported forms'};
|
|
44
|
+
if(script===null)return {run:own};
|
|
45
|
+
if(typeof scripts?.[script]!=='string')return {issue:'typecheck script was removed'};
|
|
46
|
+
if(scripts['pre'+script]||scripts['post'+script])return {issue:'typecheck script has pre or post scripts'};
|
|
47
|
+
const runs=tscRuns(scripts[script]);
|
|
48
|
+
if(runs?.length!==count||!runs.some(run=>JSON.stringify(run.args)===JSON.stringify(args)))
|
|
49
|
+
return {issue:'typecheck script changed'};
|
|
50
|
+
return {run:own};
|
|
51
|
+
}
|
|
52
|
+
// The config file a run names, relative to the directory `dir` it runs in
|
|
53
|
+
// (posix): tsconfig.json without -p, a directory's tsconfig.json, or null
|
|
54
|
+
// when it leaves that directory or the arguments are not a run.
|
|
55
|
+
export function runConfig(dir,args){
|
|
56
|
+
const run=parseRun(['tsc',...args]);
|
|
57
|
+
if(!run)return null;
|
|
58
|
+
let file=path.posix.normalize((run.project??'tsconfig.json').split(path.sep).join('/'));
|
|
59
|
+
let directory=false;try{directory=fs.statSync(path.join(dir,file)).isDirectory();}catch{}
|
|
60
|
+
if(!file.endsWith('.json')||directory)file=path.posix.join(file,'tsconfig.json');
|
|
61
|
+
if(path.posix.isAbsolute(file)||file==='..'||file.startsWith('../'))return null;
|
|
62
|
+
return file;
|
|
63
|
+
}
|
|
64
|
+
// The check names for a script's runs: the kind's name for one run, and
|
|
65
|
+
// NAME.1 ... NAME.n for a chain (not #: the hook refuses it and zsh may glob it).
|
|
66
|
+
export const partName=(name,index,count)=>count===1?name:`${name}.${index}`;
|
|
67
|
+
// Probe arguments for a run, and back: SCRIPT (or -), COUNT, then the tsc
|
|
68
|
+
// arguments.
|
|
69
|
+
export const runArgv=({script=null,args,count=1})=>[script??'-',String(count),...args];
|
|
70
|
+
export function runFromArgv(values){
|
|
71
|
+
const [script,count,...args]=values;
|
|
72
|
+
if(typeof script!=='string'||!script||!/^[1-9]\d?$/.test(count||''))throw Object.assign(Error('probe-arguments'),{code:'probe-arguments'});
|
|
73
|
+
return {script:script==='-'?null:script,args,count:Number(count)};
|
|
74
|
+
}
|
|
75
|
+
// The run a configured check makes: its script, the arguments after the
|
|
76
|
+
// compiler in its command, and how many checks the config has for the script
|
|
77
|
+
// (`runCount`, set when the config is loaded).
|
|
78
|
+
export const checkRun=check=>({script:check.script??null,
|
|
79
|
+
args:Array.isArray(check.command)?check.command.slice(2):[],count:check.runCount??1});
|
|
80
|
+
// A script's checks in the order of its runs: one check per run with the same
|
|
81
|
+
// arguments ({name, args} each), as names; null when they do not correspond.
|
|
82
|
+
export function scriptRunChecks(text,checks){
|
|
83
|
+
const runs=tscRuns(text);
|
|
84
|
+
if(!runs||runs.length!==checks.length)return null;
|
|
85
|
+
const left=[...checks],names=[];
|
|
86
|
+
for(const run of runs){
|
|
87
|
+
const at=left.findIndex(check=>JSON.stringify(check.args)===JSON.stringify(run.args));
|
|
88
|
+
if(at<0)return null;
|
|
89
|
+
names.push(left.splice(at,1)[0].name);
|
|
90
|
+
}
|
|
91
|
+
return names;
|
|
92
|
+
}
|
package/src/typescript-list.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function queriesMatch(queries) {
|
|
|
63
63
|
return queries.every(([kind,file,answer,options])=>
|
|
64
64
|
JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
|
|
65
65
|
}
|
|
66
|
-
export function compilerFiles(tsRoot,cwd) {
|
|
66
|
+
export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
|
|
67
67
|
const require=createRequire(import.meta.url),ts=require(tsRoot);
|
|
68
68
|
if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
|
|
69
69
|
const queries=new Map();
|
|
@@ -114,7 +114,7 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
114
114
|
};
|
|
115
115
|
const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
|
|
116
116
|
// `extends` of a package config resolves through realpath too.
|
|
117
|
-
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,
|
|
117
|
+
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,config),{},
|
|
118
118
|
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
|
|
119
119
|
onUnRecoverableConfigFileDiagnostic(){}});
|
|
120
120
|
if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
|
|
@@ -2,6 +2,7 @@ import {directProject,directQualification,directEnvironment} from './direct-type
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {realObservedPath} from './path-identity.mjs';
|
|
5
|
+
import {checkRun,runArgv} from './typescript-invocation.mjs';
|
|
5
6
|
|
|
6
7
|
// This is a machine-enforced contract, not a claim inferred from a script name.
|
|
7
8
|
// Runtime validation checks effective compiler membership, configuration and
|
|
@@ -11,10 +12,9 @@ export function qualifyTypeScript(root,selected,discovered={}) {
|
|
|
11
12
|
if(selected.qualification===directQualification){
|
|
12
13
|
const issues=[];
|
|
13
14
|
try{
|
|
14
|
-
const compiler=directProject(root,selected
|
|
15
|
-
if(selected.command?.
|
|
16
|
-
selected.command[1]!==compiler.entry||selected.
|
|
17
|
-
selected.cwd&&selected.cwd!=='.')issues.push('direct compiler invocation changed');
|
|
15
|
+
const compiler=directProject(root,checkRun(selected));
|
|
16
|
+
if(selected.command?.[0]!==process.execPath||
|
|
17
|
+
selected.command[1]!==compiler.entry||selected.cwd&&selected.cwd!=='.')issues.push('direct compiler invocation changed');
|
|
18
18
|
}catch(e){issues.push(e.code||'direct compiler prerequisites unavailable');}
|
|
19
19
|
if(!selected.inputs?.includes('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}')||
|
|
20
20
|
!selected.inputs?.includes('package.json')||!selected.inputs?.includes('tsconfig.json')||
|
|
@@ -24,23 +24,22 @@ export function qualifyTypeScript(root,selected,discovered={}) {
|
|
|
24
24
|
!directEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
|
|
25
25
|
issues.push('direct compiler execution context incomplete');
|
|
26
26
|
if(selected.probes?.length!==1||!selected.probes?.some(argv=>path.basename(argv[1]||'')==='direct-typescript-probe.mjs'&&
|
|
27
|
-
argv[0]===process.execPath&&argv[2]===root&&
|
|
27
|
+
argv[0]===process.execPath&&argv[2]===root&&
|
|
28
|
+
JSON.stringify(argv.slice(3))===JSON.stringify(runArgv(checkRun(selected)))))issues.push('direct compiler probe missing');
|
|
28
29
|
return {qualified:issues.length===0,issues};
|
|
29
30
|
}
|
|
30
31
|
if(selected.qualification==='pnpm-tsc-v1'){
|
|
31
32
|
const issues=[];
|
|
32
33
|
const workspace=selected.workspace||'.';
|
|
33
|
-
const script=selected.script||selected.command?.at(-1);
|
|
34
34
|
try{
|
|
35
35
|
if(discovered.issue)throw Error(discovered.issue);
|
|
36
36
|
const contract=discovered.workspace;
|
|
37
37
|
if(!contract)throw Error('pnpm TypeScript input discovery unavailable');
|
|
38
38
|
issues.push(...contract.limitations);
|
|
39
|
-
|
|
39
|
+
// The arguments are checked against the script by the contract itself.
|
|
40
40
|
if(path.resolve(root,selected.cwd||'.')!==path.join(root,contract.workspace.dir)||
|
|
41
41
|
selected.command?.[0]!==process.execPath||
|
|
42
|
-
selected.command?.[1]!==contract.tsc
|
|
43
|
-
JSON.stringify(selected.command.slice(2))!==JSON.stringify(declared))
|
|
42
|
+
selected.command?.[1]!==contract.tsc)
|
|
44
43
|
issues.push('standalone TypeScript invocation changed');
|
|
45
44
|
}catch(e){issues.push(e.message);}
|
|
46
45
|
if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='pnpm-typecheck-probe.mjs'))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Workspace package manifests: package.json files Git tracks or would add,
|
|
2
|
+
// in directories that match the workspace patterns, outside node_modules.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {spawnSync} from 'node:child_process';
|
|
6
|
+
import {patternMatcher} from './glob.mjs';
|
|
7
|
+
|
|
8
|
+
export function workspaceManifests(root,patterns) {
|
|
9
|
+
const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
10
|
+
'-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
|
|
11
|
+
maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
|
|
12
|
+
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
|
|
13
|
+
if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
|
|
14
|
+
// `!pattern` entries exclude, as in pnpm-workspace.yaml.
|
|
15
|
+
const included=patternMatcher(patterns);
|
|
16
|
+
const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
|
|
17
|
+
const dir=path.posix.dirname(file);
|
|
18
|
+
return dir!=='.'&&!dir.split('/').includes('node_modules')&&included(dir);
|
|
19
|
+
}).sort();
|
|
20
|
+
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
21
|
+
return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));
|
|
22
|
+
}
|