@redue/cli 0.1.0-alpha.17 → 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 +58 -0
- package/README.md +1 -1
- package/bin/redue.mjs +16 -6
- 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 +14 -5
- package/docs/user/tests-direct.md +8 -0
- package/package.json +2 -2
- package/src/agent-integration.mjs +2 -2
- package/src/claude-hook.mjs +100 -27
- package/src/cli.mjs +2 -2
- package/src/daemon.mjs +20 -6
- package/src/declared-plan.mjs +3 -3
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/node-onboarding.mjs +79 -31
- 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 +63 -35
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/test-direct.mjs +61 -12
- 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 +3 -4
package/src/pnpm-inputs.mjs
CHANGED
|
@@ -7,16 +7,18 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {createRequire} from 'node:module';
|
|
9
9
|
import {execFileSync,spawnSync} from 'node:child_process';
|
|
10
|
-
import micromatch from './glob.mjs';
|
|
10
|
+
import micromatch,{patternMatcher} from './glob.mjs';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import {resolveLinks} from './installed-inputs.mjs';
|
|
13
13
|
import {withinPath as within,realObservedPath,samePath} from './path-identity.mjs';
|
|
14
14
|
import {timing} from './decision-profile.mjs';
|
|
15
|
+
import {npmrc} from './npmrc.mjs';
|
|
16
|
+
import {runConfig,tscRun,tscRuns} from './typescript-invocation.mjs';
|
|
15
17
|
|
|
16
18
|
const rel=(root,file)=>path.relative(root,file).split(path.sep).join('/');
|
|
17
19
|
const read=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
18
20
|
const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
|
|
19
|
-
function yaml(file){
|
|
21
|
+
export function yaml(file){
|
|
20
22
|
const doc=YAML.parseDocument(fs.readFileSync(file,'utf8'),{uniqueKeys:true});
|
|
21
23
|
if(doc.errors.length)throw Error(`${path.basename(file)} cannot be interpreted safely`);
|
|
22
24
|
return doc.toJS()||{};
|
|
@@ -41,20 +43,6 @@ const refused=[['modulesDir','modules-dir'],['virtualStoreDir','virtual-store-di
|
|
|
41
43
|
['enableGlobalVirtualStore','enable-global-virtual-store'],['injectWorkspacePackages','inject-workspace-packages'],
|
|
42
44
|
['sharedWorkspaceLockfile','shared-workspace-lockfile'],['pnpmfile','pnpmfile'],['globalPnpmfile','global-pnpmfile'],
|
|
43
45
|
['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
46
|
// Whether a refused setting is set: present at all, except that only
|
|
59
47
|
// sharedWorkspaceLockfile=false and injectWorkspacePackages=true move files.
|
|
60
48
|
const setting=(key,value)=>value!==undefined&&
|
|
@@ -204,16 +192,37 @@ function packageAt(root,requester,name){
|
|
|
204
192
|
}
|
|
205
193
|
return {physical:null,candidates};
|
|
206
194
|
}
|
|
207
|
-
|
|
208
|
-
|
|
195
|
+
// Files TypeScript may consume from its own include: each directory its
|
|
196
|
+
// include patterns watch for new files (TypeScript's own
|
|
197
|
+
// wildcardDirectories), and each file it lists. node_modules below them is
|
|
198
|
+
// left out, as TypeScript's wildcards leave it out; installed files a program
|
|
199
|
+
// reads are installed inputs. `exclude` entries stay inputs: an import can
|
|
200
|
+
// still reach an excluded file.
|
|
201
|
+
const sourceFiles='*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}';
|
|
202
|
+
function sourcePatterns(root,parsed){
|
|
203
|
+
const patterns=new Set(),excluded=new Set();
|
|
204
|
+
for(const [folder,flags] of Object.entries(parsed.wildcardDirectories||{})){
|
|
205
|
+
// TypeScript 4 lowercases these keys on case-insensitive file systems;
|
|
206
|
+
// the native realpath gives the directory's own spelling.
|
|
207
|
+
let at;try{at=fs.realpathSync.native(folder);}catch{at=path.resolve(folder);}
|
|
208
|
+
if(!within(at,root))throw Error('TypeScript source membership escapes the checkout');
|
|
209
|
+
const prefix=rel(root,at)?rel(root,at)+'/':'';
|
|
210
|
+
if(flags&1){patterns.add(`${prefix}**/${sourceFiles}`);excluded.add(`!${prefix}**/node_modules/**`);}
|
|
211
|
+
else patterns.add(prefix+sourceFiles);
|
|
212
|
+
}
|
|
213
|
+
const included=patternMatcher([...patterns,...excluded]);
|
|
214
|
+
for(const file of parsed.fileNames.map(name=>path.resolve(name))){
|
|
215
|
+
if(!within(file,root))throw Error('TypeScript source membership escapes the checkout');
|
|
216
|
+
if(!included(rel(root,file)))patterns.add(rel(root,file));
|
|
217
|
+
}
|
|
218
|
+
return [...patterns,...excluded];
|
|
219
|
+
}
|
|
220
|
+
function parsedTypeScript(root,dir,ts,configName='tsconfig.json'){
|
|
221
|
+
const config=path.join(root,dir,configName),reads=new Set();
|
|
209
222
|
const host={...ts.sys,readFile(file){reads.add(path.resolve(file));return ts.sys.readFile(file);},
|
|
210
223
|
onUnRecoverableConfigFileDiagnostic(){}};
|
|
211
224
|
const parsed=ts.getParsedCommandLineOfConfigFile(config,{},host);
|
|
212
225
|
if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
|
|
213
|
-
const raw=ts.readConfigFile(config,ts.sys.readFile);
|
|
214
|
-
if(raw.error||!Array.isArray(raw.config?.include)||raw.config.include.length!==1||
|
|
215
|
-
!/^src(?:$|\/)/.test(raw.config.include[0]))
|
|
216
|
-
throw Error('TypeScript source file membership is outside the supported src include');
|
|
217
226
|
if(parsed.options.plugins?.length||parsed.options.incremental||parsed.options.composite||
|
|
218
227
|
parsed.projectReferences?.length||parsed.options.paths||parsed.options.baseUrl||
|
|
219
228
|
parsed.options.rootDirs?.length||parsed.options.typeRoots?.length||
|
|
@@ -225,12 +234,13 @@ function parsedTypeScript(root,dir,ts){
|
|
|
225
234
|
throw Error('TypeScript module resolver is outside the supported Node contract');
|
|
226
235
|
for(const file of reads)if(!within(realObservedPath(file),root))
|
|
227
236
|
throw Error('TypeScript configuration escapes the checkout');
|
|
228
|
-
return {parsed,configs:[...reads].map(file=>rel(root,file)).sort()
|
|
237
|
+
return {parsed,configs:[...reads].map(file=>rel(root,file)).sort(),
|
|
238
|
+
sourcePatterns:sourcePatterns(root,parsed)};
|
|
229
239
|
}
|
|
230
|
-
function listedTypeScriptFiles(root,selected,tsc){
|
|
240
|
+
function listedTypeScriptFiles(root,selected,tsc,configName){
|
|
231
241
|
// --listFilesOnly resolves the compiler's effective module/file set but
|
|
232
242
|
// performs no typecheck and emits no project outputs.
|
|
233
|
-
const out=spawnSync(process.execPath,[tsc,'-p',
|
|
243
|
+
const out=spawnSync(process.execPath,[tsc,'-p',configName,'--listFilesOnly'],
|
|
234
244
|
{cwd:path.join(root,selected.dir),env:process.env,encoding:'utf8',
|
|
235
245
|
// The native NTFS public workspace takes 3-5 s to list ~770 inputs;
|
|
236
246
|
// 5 s sometimes expired despite bounded, progressing compiler work.
|
|
@@ -267,17 +277,23 @@ function sharedHardlinkIn(folder){
|
|
|
267
277
|
}
|
|
268
278
|
return false;
|
|
269
279
|
}
|
|
270
|
-
|
|
280
|
+
// `run`: the check's {script, part, args}, or a script name for its only run.
|
|
281
|
+
export function pnpmTypecheckInputs(root,workspace,run,{captureQueries=false}={}){
|
|
271
282
|
const totalStarted=performance.now();
|
|
272
283
|
root=realObservedPath(root);
|
|
273
284
|
const install=pnpmInstallInfo(root),members=pnpmWorkspacePackages(root,install.patterns);
|
|
274
285
|
const selected=workspace==='.'?{name:'.',dir:'.',manifest:'package.json',
|
|
275
286
|
pkg:read(path.join(root,'package.json'))}:members.get(workspace);
|
|
276
287
|
if(!selected)throw Error(`workspace ${workspace} is not declared`);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
288
|
+
// A script name stands for its only run.
|
|
289
|
+
if(typeof run==='string'){const runs=tscRuns(selected.pkg.scripts?.[run]);run={script:run,args:runs?.length===1?runs[0].args:[]};}
|
|
290
|
+
const {run:own,issue}=tscRun(selected.pkg.scripts,run);
|
|
291
|
+
if(issue)throw Error(`typecheck must be standalone TypeScript runs: ${issue}`);
|
|
292
|
+
// `tsc -p .` without --noEmit predates the other forms.
|
|
293
|
+
if(!own.noEmit&&!['-p .','-p tsconfig.json'].includes(run.args.join(' ')))
|
|
294
|
+
throw Error('typecheck must pass --noEmit');
|
|
295
|
+
const configName=runConfig(path.join(root,selected.dir),run.args);
|
|
296
|
+
if(!configName)throw Error('TypeScript project is outside the package');
|
|
281
297
|
const selectedDir=path.join(root,selected.dir),lookup=createRequire(path.join(selectedDir,'package.json'));
|
|
282
298
|
let tsPackage;try{tsPackage=realObservedPath(lookup.resolve('typescript/package.json'));}
|
|
283
299
|
catch{throw Error('installed TypeScript compiler cannot be resolved');}
|
|
@@ -286,7 +302,8 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
|
|
|
286
302
|
const tsc=path.join(tsRoot,'bin','tsc');
|
|
287
303
|
if(!exists(tsc))throw Error('installed TypeScript executable is missing');
|
|
288
304
|
const configStarted=performance.now();
|
|
289
|
-
const {configs}=parsedTypeScript(root,selected.dir,ts);
|
|
305
|
+
const {configs,sourcePatterns:sources}=parsedTypeScript(root,selected.dir,ts,configName);
|
|
306
|
+
const isSource=patternMatcher(sources);
|
|
290
307
|
timing('pnpm.ts_config',configStarted,{configs:configs.length});
|
|
291
308
|
const closure=new Map(),installed=new Set(),generated=new Set(),
|
|
292
309
|
links=new Set(),candidates=new Set(),absences=[],instances=new Map();
|
|
@@ -336,12 +353,12 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
|
|
|
336
353
|
resolutionMs:Math.round(resolutionMs),workspaceLinks:links.size,
|
|
337
354
|
candidates:candidates.size});
|
|
338
355
|
const listingStarted=performance.now();
|
|
339
|
-
const captured=captureQueries?compilerFiles(tsRoot,path.join(root,selected.dir)):null;
|
|
340
|
-
const files=captured?captured.files.map(realObservedPath):listedTypeScriptFiles(root,selected,tsc);
|
|
356
|
+
const captured=captureQueries?compilerFiles(tsRoot,path.join(root,selected.dir),configName):null;
|
|
357
|
+
const files=captured?captured.files.map(realObservedPath):listedTypeScriptFiles(root,selected,tsc,configName);
|
|
341
358
|
timing('pnpm.ts_input_listing',listingStarted,{files:files.length});
|
|
342
359
|
const mappingStarted=performance.now();
|
|
343
360
|
for(const file of files){
|
|
344
|
-
if(
|
|
361
|
+
if(isSource(rel(root,file))||
|
|
345
362
|
[...closure.values()].some(member=>within(file,path.join(root,member.dir))))continue;
|
|
346
363
|
const packageRoot=installedPackageRoot(root,file);
|
|
347
364
|
if(!packageRoot)throw Error(`TypeScript consumes a file outside the observed pnpm contract: ${rel(root,file)}`);
|
|
@@ -352,6 +369,16 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
|
|
|
352
369
|
}
|
|
353
370
|
timing('pnpm.input_mapping',mappingStarted,{instances:instances.size,
|
|
354
371
|
installedPatterns:installed.size});
|
|
372
|
+
// A config extended from an installed package is an installed input too:
|
|
373
|
+
// source patterns leave node_modules out.
|
|
374
|
+
for(const config of configs){
|
|
375
|
+
const file=realObservedPath(path.join(root,config));
|
|
376
|
+
if(!within(file,path.join(root,'node_modules')))continue;
|
|
377
|
+
const packageRoot=installedPackageRoot(root,file);
|
|
378
|
+
if(!packageRoot)throw Error(`TypeScript configuration ${config} is outside an installed package`);
|
|
379
|
+
installed.add(`${rel(root,packageRoot)}/**`);
|
|
380
|
+
installed.add(`!${rel(root,packageRoot)}/node_modules/**`);
|
|
381
|
+
}
|
|
355
382
|
installed.add(`${rel(root,tsRoot)}/**`);
|
|
356
383
|
installed.add(`!${rel(root,tsRoot)}/node_modules/**`);
|
|
357
384
|
if(!instances.has(tsRoot))instances.set(tsRoot,{name:'typescript',physicalRoot:rel(root,tsRoot)});
|
|
@@ -360,11 +387,12 @@ export function pnpmTypecheckInputs(root,workspace,script,{captureQueries=false}
|
|
|
360
387
|
timing('pnpm.hardlink_inspection',sharedStarted,{instances:instances.size,
|
|
361
388
|
shared:shared.length});
|
|
362
389
|
const source=[...new Set([...install.configurationInputs,selected.manifest,
|
|
363
|
-
...configs
|
|
390
|
+
...configs,...sources,
|
|
364
391
|
...closure.values().map(member=>member.manifest),...generated])];
|
|
365
392
|
timing('pnpm.total_discovery',totalStarted,{instances:instances.size,
|
|
366
393
|
inputFiles:files.length});
|
|
367
394
|
return {install,workspace:selected,closure:[...closure.values()],source,
|
|
395
|
+
sourcePatterns:sources,args:run.args,
|
|
368
396
|
installed:[...installed],generated:[...generated],
|
|
369
397
|
// The read-only state probe consumes this same resolved compiler file set.
|
|
370
398
|
// It is deliberately omitted from the persisted user-facing input plan.
|
|
@@ -7,14 +7,17 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {createHash} from 'node:crypto';
|
|
9
9
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
10
|
+
import {patternMatcher} from './glob.mjs';
|
|
10
11
|
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
12
|
+
import {runFromArgv} from './typescript-invocation.mjs';
|
|
11
13
|
|
|
12
|
-
const [rootArg,workspace
|
|
14
|
+
const [rootArg,workspace,...rest]=process.argv.slice(2).filter(value=>!value.startsWith('--redue-'));
|
|
13
15
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
14
16
|
const digest=file=>hash(fs.readFileSync(file));
|
|
15
17
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
16
18
|
try{
|
|
17
|
-
if(!rootArg||!workspace
|
|
19
|
+
if(!rootArg||!workspace)fail('probe-arguments');
|
|
20
|
+
const run=runFromArgv(rest);
|
|
18
21
|
const root=realObservedPath(rootArg);
|
|
19
22
|
if(process.env.NODE_OPTIONS||process.env.NODE_PATH||process.env.BASH_ENV||
|
|
20
23
|
process.env.ENV||Object.entries(process.env).some(([key,value])=>{
|
|
@@ -24,23 +27,25 @@ try{
|
|
|
24
27
|
return /^(PNPM_|COREPACK_|npm_config_|DYLD_|TSGO_)/i.test(key);
|
|
25
28
|
}))
|
|
26
29
|
fail('pnpm-execution-environment-unsupported');
|
|
27
|
-
const contextHash=hash(JSON.stringify([root,workspace,
|
|
30
|
+
const contextHash=hash(JSON.stringify([root,workspace,run,process.version]));
|
|
28
31
|
if(contextOnly(contextHash))process.exit(0);
|
|
29
|
-
const contract=pnpmTypecheckInputs(root,workspace,
|
|
32
|
+
const contract=pnpmTypecheckInputs(root,workspace,run,{captureQueries:process.argv.includes('--redue-checkpoint')});
|
|
30
33
|
if(contract.limitations.length)fail('pnpm-input-coverage-unavailable');
|
|
31
34
|
const selected=path.join(root,contract.workspace.dir),tsc=contract.tsc;
|
|
32
35
|
const inputs=contract.listedFiles;
|
|
33
36
|
if(!inputs.length)fail('typescript-input-list-unavailable');
|
|
34
|
-
const inputRoots=[
|
|
37
|
+
const inputRoots=[...contract.generated.map(pattern=>
|
|
35
38
|
path.join(root,pattern.slice(0,-3))),...contract.installed.filter(p=>!p.startsWith('!'))
|
|
36
39
|
.map(pattern=>path.join(root,pattern.slice(0,-3)))];
|
|
40
|
+
const isSource=patternMatcher(contract.sourcePatterns);
|
|
37
41
|
for(const file of inputs)if(!within(file,root)||
|
|
42
|
+
!isSource(path.relative(root,file).split(path.sep).join('/'))&&
|
|
38
43
|
!inputRoots.some(boundary=>within(file,boundary)))
|
|
39
44
|
fail('typescript-input-outside-pnpm-contract');
|
|
40
45
|
const files=[...new Set(inputs)].sort().map(file=>
|
|
41
46
|
[hash(path.relative(root,file)),digest(file)]);
|
|
42
47
|
const configs=contract.configs.map(file=>[hash(file),digest(path.join(root,file))]);
|
|
43
|
-
probeResult(hash(JSON.stringify({schema:1,workspace,
|
|
48
|
+
probeResult(hash(JSON.stringify({schema:1,workspace,run,
|
|
44
49
|
files,configs,node:process.version,tsc:digest(tsc),
|
|
45
50
|
packageManager:contract.install.version,linker:contract.install.linker})),contextHash,contract.discoveryQueries);
|
|
46
51
|
}catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:
|
package/src/test-direct.mjs
CHANGED
|
@@ -10,11 +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} from './pnpm-inputs.mjs';
|
|
16
|
+
import {pnpmInstallInfo,pnpmProjectInfo,yaml} from './pnpm-inputs.mjs';
|
|
17
17
|
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
18
|
+
import {atomicJson} from './state-store.mjs';
|
|
19
|
+
import {racyUntil} from './path-identity.mjs';
|
|
18
20
|
|
|
19
21
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
20
22
|
export const testRecorder=fileURLToPath(new URL('test-recorder.cjs',import.meta.url));
|
|
@@ -65,9 +67,7 @@ export function testRunGuard(dir,{root,sourcePatterns,files,runner,environment=t
|
|
|
65
67
|
const compared=name=>(environment.variables||[]).includes(name)||
|
|
66
68
|
(environment.prefixes||[]).some(prefix=>name.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
67
69
|
const issues=[],add=issue=>{if(!issues.includes(issue))issues.push(issue);};
|
|
68
|
-
const
|
|
69
|
-
const negative=sourcePatterns.filter(p=>p.startsWith('!')).map(p=>micromatch.matcher(p.slice(1),{dot:true}));
|
|
70
|
-
const included=rel=>positive.some(fn=>fn(rel))&&!negative.some(fn=>fn(rel));
|
|
70
|
+
const included=patternMatcher(sourcePatterns);
|
|
71
71
|
const observed=rel=>Object.hasOwn(files||{},rel);
|
|
72
72
|
const headers=new Map(),records=[];
|
|
73
73
|
let names=[];
|
|
@@ -336,21 +336,70 @@ export function testWorkspaceDirs(root,pkg){
|
|
|
336
336
|
// Each node_modules the recipe observes as installed: the root's and every
|
|
337
337
|
// workspace package's, relative to the root.
|
|
338
338
|
const modulesDirs=({dirs=[]})=>['node_modules',...dirs.map(dir=>`${dir}/node_modules`)];
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
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){
|
|
344
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});
|
|
345
376
|
while(stack.length){
|
|
346
377
|
const dir=stack.pop();let entries;
|
|
347
378
|
try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
|
|
348
379
|
for(const entry of entries){
|
|
349
380
|
const file=path.join(dir,entry.name);
|
|
350
|
-
if(entry.isDirectory())stack.push(file);
|
|
351
|
-
|
|
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;
|
|
352
399
|
}
|
|
353
400
|
}
|
|
401
|
+
if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
|
|
402
|
+
try{atomicJson(cache,{files,entries:found});}catch{}
|
|
354
403
|
return null;
|
|
355
404
|
}
|
|
356
405
|
// The runner a package's script reaches: Node's lookup from the package
|
|
@@ -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'))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import {spawnSync} from 'node:child_process';
|
|
6
|
-
import
|
|
6
|
+
import {patternMatcher} from './glob.mjs';
|
|
7
7
|
|
|
8
8
|
export function workspaceManifests(root,patterns) {
|
|
9
9
|
const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
@@ -12,11 +12,10 @@ export function workspaceManifests(root,patterns) {
|
|
|
12
12
|
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
|
|
13
13
|
if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
|
|
14
14
|
// `!pattern` entries exclude, as in pnpm-workspace.yaml.
|
|
15
|
-
const
|
|
15
|
+
const included=patternMatcher(patterns);
|
|
16
16
|
const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
|
|
17
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}));
|
|
18
|
+
return dir!=='.'&&!dir.split('/').includes('node_modules')&&included(dir);
|
|
20
19
|
}).sort();
|
|
21
20
|
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
22
21
|
return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));
|