@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +491 -0
- package/README.md +107 -2
- package/bin/redue.mjs +132 -50
- package/docs/README.md +2 -1
- package/docs/user/agents.md +299 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +209 -0
- package/docs/user/getting-started.md +23 -2
- package/docs/user/json.md +2 -0
- package/docs/user/node-script.md +154 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/docs/user/supported-projects.md +56 -23
- package/docs/user/tests-direct.md +132 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +388 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +46 -8
- package/src/claude-hook.mjs +449 -0
- package/src/cli.mjs +263 -40
- package/src/daemon.mjs +433 -94
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +10 -0
- package/src/declared-plan.mjs +29 -6
- package/src/direct-run-certificate.mjs +252 -0
- package/src/direct-typescript-probe.mjs +24 -0
- package/src/direct-typescript.mjs +185 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +72 -19
- package/src/installed-inputs.mjs +7 -1
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +172 -53
- package/src/node-script.mjs +364 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +114 -54
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +9 -1
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/run-recorder.cjs +91 -0
- package/src/state-store.mjs +37 -1
- package/src/test-direct.mjs +560 -0
- package/src/test-recorder.cjs +406 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +108 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Extracted from the existing npm TypeScript probe; shared compiler input logic.
|
|
2
|
+
import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';
|
|
3
|
+
import {createRequire} from 'node:module';import {spawnSync} from 'node:child_process';
|
|
4
|
+
import {compilerFiles} from './typescript-list.mjs';
|
|
5
|
+
import {buildInfoFor} from './direct-typescript.mjs';
|
|
6
|
+
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
7
|
+
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
8
|
+
import {timing} from './decision-profile.mjs';
|
|
9
|
+
const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const digestFile=file=>hash(fs.readFileSync(file));
|
|
11
|
+
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
12
|
+
export function typeScriptInputFacts(root,tsRoot,tsc,config='tsconfig.json'){
|
|
13
|
+
const configReads=new Set(),visited=new Set(),referenced=[];
|
|
14
|
+
let buildInfo=null;
|
|
15
|
+
let ts;try{ts=require(tsRoot);}catch{}
|
|
16
|
+
if(typeof ts?.getParsedCommandLineOfConfigFile==='function'){
|
|
17
|
+
const host={...ts.sys,readFile(file){configReads.add(path.resolve(file));
|
|
18
|
+
return ts.sys.readFile(file);},onUnRecoverableConfigFileDiagnostic(){}};
|
|
19
|
+
const parseConfig=(file,top=false)=>{
|
|
20
|
+
const absolute=path.resolve(file);
|
|
21
|
+
if(!within(absolute,root))fail('typescript-config-outside-repository');
|
|
22
|
+
if(visited.has(absolute))return;visited.add(absolute);
|
|
23
|
+
if(visited.size>64)fail('typescript-config-graph-too-large');
|
|
24
|
+
const parsed=ts.getParsedCommandLineOfConfigFile(absolute,{},host);
|
|
25
|
+
if(!parsed||parsed.errors?.length)fail('typescript-config-unavailable');
|
|
26
|
+
if(parsed.options?.plugins?.length)fail('typescript-plugin-unsupported');
|
|
27
|
+
// Plain tsc reads a referenced config but never builds it, so only the
|
|
28
|
+
// root config's build settings matter. An incremental root reads and
|
|
29
|
+
// writes one build-info file, which must be inside the checkout.
|
|
30
|
+
if(top){
|
|
31
|
+
if(parsed.options?.composite)fail('typescript-incremental-unsupported');
|
|
32
|
+
const found=buildInfoFor(ts,parsed.options,root);
|
|
33
|
+
if(found.issue)fail(found.issue);
|
|
34
|
+
buildInfo=found.file||null;
|
|
35
|
+
}else referenced.push(parsed);
|
|
36
|
+
for(const reference of parsed.projectReferences||[])
|
|
37
|
+
parseConfig(reference.path.endsWith('.json')?reference.path:
|
|
38
|
+
path.join(reference.path,'tsconfig.json'));
|
|
39
|
+
};
|
|
40
|
+
parseConfig(path.join(root,config),true);
|
|
41
|
+
}else{
|
|
42
|
+
// TypeScript 7's native compiler does not expose the previous config API.
|
|
43
|
+
// Its CLI reports the effective config; restrict this path to one direct
|
|
44
|
+
// config rather than guessing about extends/reference provenance.
|
|
45
|
+
const file=path.join(root,config),raw=fs.readFileSync(file,'utf8');
|
|
46
|
+
if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
|
|
47
|
+
fail('typescript-config-graph-unsupported');
|
|
48
|
+
const shown=spawnSync(process.execPath,[tsc,'--showConfig','-p',config],{cwd:root,
|
|
49
|
+
env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
|
|
50
|
+
stdio:['ignore','pipe','pipe']});
|
|
51
|
+
if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
|
|
52
|
+
const effective=JSON.parse(shown.stdout);
|
|
53
|
+
if(effective.compilerOptions?.plugins?.length||
|
|
54
|
+
effective.compilerOptions?.incremental||effective.compilerOptions?.composite)
|
|
55
|
+
fail('typescript-config-graph-unsupported');
|
|
56
|
+
configReads.add(file);
|
|
57
|
+
}
|
|
58
|
+
for(const file of configReads)if(!within(realObservedPath(file),root))
|
|
59
|
+
fail('typescript-config-outside-repository');
|
|
60
|
+
const listingStarted=performance.now();
|
|
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
|
+
{cwd:root,env:process.env,encoding:'utf8',timeout:probeTimeLeft(process.platform==='win32'?10000:4000),
|
|
64
|
+
maxBuffer:16*1024*1024,
|
|
65
|
+
stdio:['ignore','pipe','pipe']});
|
|
66
|
+
timing('typescript_contract.list_files',listingStarted,{status:listed.status??null,
|
|
67
|
+
error:listed.error?.code||null});
|
|
68
|
+
if(listed.error||listed.signal||listed.status!==0)fail('typescript-input-list-unavailable');
|
|
69
|
+
const files=listed.stdout.trim().split(/\r?\n/).filter(Boolean)
|
|
70
|
+
.map(file=>path.resolve(file));
|
|
71
|
+
if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
|
|
72
|
+
fail('typescript-input-outside-repository');
|
|
73
|
+
// A program that uses a referenced project's sources or outputs depends on
|
|
74
|
+
// that project's build, which plain tsc does not run. tsc redirects only
|
|
75
|
+
// TypeScript sources; a shared .d.ts or .json is an ordinary input.
|
|
76
|
+
const declaration=/\.d\.[cm]?ts$|\.json$/;
|
|
77
|
+
const sources=new Set(),outputs=new Set(),outDirs=[];
|
|
78
|
+
for(const parsed of referenced){
|
|
79
|
+
const dirs=[parsed.options.outDir,parsed.options.declarationDir].filter(Boolean).map(dir=>path.resolve(dir));
|
|
80
|
+
outDirs.push(...dirs);
|
|
81
|
+
for(const file of parsed.fileNames.map(name=>path.resolve(name)))if(!declaration.test(file)){
|
|
82
|
+
sources.add(file);if(!dirs.length)outputs.add(file.replace(/\.[cm]?tsx?$/,ext=>ext.replace(/tsx?$/,'d.ts')));}
|
|
83
|
+
}
|
|
84
|
+
if(files.some(file=>sources.has(file)||outputs.has(file)||outDirs.some(dir=>within(file,dir))))
|
|
85
|
+
fail('typescript-project-reference-consumed');
|
|
86
|
+
// Every hashed configuration must also be a certified question.
|
|
87
|
+
const queries=captured?.queries&&[...configReads].every(file=>
|
|
88
|
+
captured.queries.some(([kind,read])=>kind==='readFile'&&read===file))?captured.queries:null;
|
|
89
|
+
return {configs:[...configReads].sort().map(file=>[file,digestFile(file)]),
|
|
90
|
+
files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries,
|
|
91
|
+
...(buildInfo?{buildInfo}:{})};
|
|
92
|
+
}
|
|
@@ -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
|
@@ -5,55 +5,122 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {createHash} from 'node:crypto';
|
|
7
7
|
import {createRequire} from 'node:module';
|
|
8
|
+
import {identityFields,racyUntil} from './path-identity.mjs';
|
|
8
9
|
const require=createRequire(import.meta.url);
|
|
9
10
|
const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
10
11
|
export function queryAnswer(kind,file,options) {
|
|
11
12
|
try{
|
|
12
|
-
if(kind==='readDirectory')return require(options.tsRoot).sys.readDirectory(file
|
|
13
|
+
if(kind==='readDirectory')return require(options.tsRoot).sys.readDirectory(file,
|
|
14
|
+
...options.args.map((value,i)=>options.undefinedArguments?.includes(i)?undefined:value)).sort();
|
|
13
15
|
if(kind==='readFile')return hash(fs.readFileSync(file,'utf8'));
|
|
14
16
|
if(kind==='fileExists')return fs.statSync(file).isFile();
|
|
15
17
|
if(kind==='directoryExists')return fs.statSync(file).isDirectory();
|
|
16
|
-
|
|
18
|
+
// The compiler resolves through realpathSync.native outside Windows.
|
|
19
|
+
if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
|
|
17
20
|
if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
|
|
18
21
|
.filter(entry=>entry.isDirectory()||entry.isSymbolicLink()&&
|
|
19
22
|
fs.statSync(path.join(file,entry.name)).isDirectory()).map(entry=>entry.name).sort();
|
|
20
23
|
// Directory entries include names/types, including newly created candidates.
|
|
21
24
|
if(kind==='entries')return fs.readdirSync(file,{withFileTypes:true})
|
|
22
25
|
.map(entry=>[entry.name,entry.isDirectory()?'d':entry.isSymbolicLink()?'l':'f']).sort();
|
|
26
|
+
// A compiler directory listing: subdirectories and files with a requested
|
|
27
|
+
// extension, classifying links by their targets as matchFiles does.
|
|
28
|
+
if(kind==='listing'){
|
|
29
|
+
const suffixes=options?.extensions?.map(value=>value.toLowerCase());
|
|
30
|
+
return fs.readdirSync(file,{withFileTypes:true}).flatMap(entry=>{
|
|
31
|
+
let type=entry;
|
|
32
|
+
if(entry.isSymbolicLink())try{type=fs.statSync(path.join(file,entry.name));}catch{return [];}
|
|
33
|
+
if(type.isDirectory())return [[entry.name,'d']];
|
|
34
|
+
if(type.isFile()&&(!suffixes||suffixes.some(value=>entry.name.toLowerCase().endsWith(value))))
|
|
35
|
+
return [[entry.name,'f']];
|
|
36
|
+
return [];
|
|
37
|
+
}).sort();
|
|
38
|
+
}
|
|
23
39
|
throw Error('unknown compiler filesystem question');
|
|
24
40
|
}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
25
41
|
}
|
|
42
|
+
// Null when every run fact answers as it did during the run, otherwise the
|
|
43
|
+
// first path that does not. Asked in-process; nothing is launched.
|
|
44
|
+
export function runFactChange(facts) {
|
|
45
|
+
if(!Array.isArray(facts))return 'an unreadable run fact';
|
|
46
|
+
for(const fact of facts){
|
|
47
|
+
if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries'].includes(fact[0])||
|
|
48
|
+
typeof fact[1]!=='string')return 'an unreadable run fact';
|
|
49
|
+
const [kind,file,answer]=fact;
|
|
50
|
+
let now;try{now=queryAnswer(kind,file);}catch{return file;}
|
|
51
|
+
// A test run's directory listings are arrays.
|
|
52
|
+
if(kind==='entries'?JSON.stringify(now)!==JSON.stringify(answer):now!==answer)return file;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// Why a receipt's run facts no longer hold, or null when they all do.
|
|
57
|
+
export function runFactReason(facts) {
|
|
58
|
+
const changed=runFactChange(facts);
|
|
59
|
+
return changed&&`a lookup the last run made answers differently now: ${changed}`;
|
|
60
|
+
}
|
|
26
61
|
export function queriesMatch(queries) {
|
|
27
62
|
if(!queries?.length)return false;
|
|
28
63
|
return queries.every(([kind,file,answer,options])=>
|
|
29
64
|
JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
|
|
30
65
|
}
|
|
31
|
-
export function compilerFiles(tsRoot,cwd) {
|
|
66
|
+
export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
|
|
32
67
|
const require=createRequire(import.meta.url),ts=require(tsRoot);
|
|
33
68
|
if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
|
|
34
69
|
const queries=new Map();
|
|
35
|
-
const remember=(kind,file)=>{
|
|
36
|
-
const full=path.resolve(file),answer=queryAnswer(kind,full);
|
|
37
|
-
queries.set(kind+'\0'+full,
|
|
70
|
+
const remember=(kind,file,options)=>{
|
|
71
|
+
const full=path.resolve(file),answer=queryAnswer(kind,full,options);
|
|
72
|
+
queries.set(kind+'\0'+full+(options?JSON.stringify(options):''),
|
|
73
|
+
options?[kind,full,answer,options]:[kind,full,answer]);return answer;
|
|
38
74
|
};
|
|
39
75
|
const readFile=file=>{remember('readFile',file);return ts.sys.readFile(file);};
|
|
40
76
|
const fileExists=file=>{remember('fileExists',file);return ts.sys.fileExists(file);};
|
|
41
77
|
const directoryExists=file=>{remember('directoryExists',file);return ts.sys.directoryExists(file);};
|
|
42
78
|
const getDirectories=file=>{remember('directories',file);return ts.sys.getDirectories(file);};
|
|
43
79
|
const readDirectory=(dir,...args)=>{
|
|
44
|
-
|
|
45
|
-
|
|
80
|
+
// Reviewed compilers enumerate each visited directory through fs.readdirSync.
|
|
81
|
+
// Record those listings as ordinary questions so a validator without the
|
|
82
|
+
// compiler API can replay this answer; the listing itself stays authoritative.
|
|
83
|
+
const listed=[],readdirSync=fs.readdirSync;
|
|
84
|
+
fs.readdirSync=function(file,...rest){listed.push(path.resolve(String(file)));
|
|
85
|
+
return readdirSync.call(this,file,...rest);};
|
|
86
|
+
let result;try{result=ts.sys.readDirectory(dir,...args);}finally{fs.readdirSync=readdirSync;}
|
|
87
|
+
// JSON turns undefined array entries into null. TypeScript distinguishes
|
|
88
|
+
// them (an omitted exclude list is not a null list), so retain that fact.
|
|
89
|
+
const full=path.resolve(dir),options={tsRoot,args,argsVersion:1,
|
|
90
|
+
undefinedArguments:args.flatMap((value,i)=>value===undefined?[i]:[])};
|
|
91
|
+
const visited=new Set(listed);
|
|
92
|
+
if(typeof ts.getFileMatcherPatterns==='function'&&
|
|
93
|
+
result.every(file=>visited.has(path.dirname(path.resolve(file)))))try{
|
|
94
|
+
// Base paths and symlinked directories reach matchFiles through realpath
|
|
95
|
+
// deduplication without a listing; a retargeted link changes this answer.
|
|
96
|
+
for(const base of ts.getFileMatcherPatterns(full,args[1],args[2],
|
|
97
|
+
ts.sys.useCaseSensitiveFileNames,process.cwd()).basePaths)remember('realpath',base);
|
|
98
|
+
const extensions=Array.isArray(args[0])?args[0]:null;
|
|
99
|
+
for(const directory of visited){
|
|
100
|
+
// matchFiles also resolves every visited directory to detect cycles.
|
|
101
|
+
remember('realpath',directory);
|
|
102
|
+
remember('listing',directory,{extensions});
|
|
103
|
+
let entries=[];
|
|
104
|
+
try{entries=fs.readdirSync(directory,{withFileTypes:true});}
|
|
105
|
+
catch(error){if(!['ENOENT','ENOTDIR'].includes(error.code))throw error;}
|
|
106
|
+
for(const entry of entries)
|
|
107
|
+
if(entry.isSymbolicLink())remember('realpath',path.join(directory,entry.name));
|
|
108
|
+
}
|
|
109
|
+
options.covered=1;
|
|
110
|
+
}catch{/* uncovered listings are replayed only through the compiler API */}
|
|
46
111
|
queries.set('readDirectory\0'+full+JSON.stringify(args),
|
|
47
112
|
['readDirectory',full,[...result].sort(),options]);
|
|
48
113
|
return result;
|
|
49
114
|
};
|
|
50
|
-
const
|
|
51
|
-
|
|
115
|
+
const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
|
|
116
|
+
// `extends` of a package config resolves through realpath too.
|
|
117
|
+
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,config),{},
|
|
118
|
+
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
|
|
52
119
|
onUnRecoverableConfigFileDiagnostic(){}});
|
|
53
120
|
if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
|
|
54
121
|
const host=ts.createCompilerHost(parsed.options);
|
|
55
|
-
Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,
|
|
56
|
-
realpath
|
|
122
|
+
Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,readDirectory,
|
|
123
|
+
realpath,
|
|
57
124
|
getSourceFile(file,languageVersion,onError){
|
|
58
125
|
const text=readFile(file);return text===undefined?undefined:
|
|
59
126
|
ts.createSourceFile(file,text,languageVersion);
|
|
@@ -64,5 +131,33 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
64
131
|
// Stable answers are necessary even at first capture; a concurrent edit
|
|
65
132
|
// cannot become the basis for a reusable discovery result.
|
|
66
133
|
const facts=[...queries.values()];
|
|
67
|
-
|
|
134
|
+
// A certificate must cover every program file. Referenced configurations
|
|
135
|
+
// are parsed through this host, so their reads and listings are certified.
|
|
136
|
+
const complete=files.every(file=>queries.has('readFile\0'+file));
|
|
137
|
+
return {files,queries:complete&&queriesMatch(facts)?facts:null};
|
|
138
|
+
}
|
|
139
|
+
// Identity of a read file and, for a link, of its target. Writes through any
|
|
140
|
+
// alias change ctime. A file changed within the last 2 s has no identity, so a
|
|
141
|
+
// coarse timestamp cannot hide a second write in the same tick (git's racy rule).
|
|
142
|
+
export function fileIdentity(file,now=Date.now()) {
|
|
143
|
+
try{const link=fs.lstatSync(file,{bigint:true}),target=link.isSymbolicLink()?fs.statSync(file,{bigint:true}):link;
|
|
144
|
+
if(racyUntil(link)>=now||racyUntil(target)>=now)return null;
|
|
145
|
+
const fields=st=>identityFields.map(key=>String(st[key]));
|
|
146
|
+
return JSON.stringify([fields(target),target===link?null:[fs.realpathSync(file),...fields(link)]]);}
|
|
147
|
+
catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
148
|
+
}
|
|
149
|
+
// In-process replay of a certificate. Covered directory listings are checked
|
|
150
|
+
// through their recorded listings instead of loading the compiler API. A read
|
|
151
|
+
// file is re-hashed until a match records its identity, taken before the read,
|
|
152
|
+
// and again whenever that identity changes.
|
|
153
|
+
export function queriesHold(queries,identities=new Map()) {
|
|
154
|
+
if(!queries?.length)return false;
|
|
155
|
+
return queries.every(([kind,file,answer,options])=>{
|
|
156
|
+
if(kind==='readDirectory')return options?.covered===1;
|
|
157
|
+
const identity=kind==='readFile'?fileIdentity(file):null;
|
|
158
|
+
if(identity&&identities.get(file)===identity)return true;
|
|
159
|
+
const held=JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer);
|
|
160
|
+
if(kind==='readFile'){if(held&&identity)identities.set(file,identity);else identities.delete(file);}
|
|
161
|
+
return held;
|
|
162
|
+
});
|
|
68
163
|
}
|
|
@@ -1,25 +1,45 @@
|
|
|
1
|
+
import {directProject,directQualification,directEnvironment} from './direct-typescript.mjs';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {realObservedPath} from './path-identity.mjs';
|
|
5
|
+
import {checkRun,runArgv} from './typescript-invocation.mjs';
|
|
4
6
|
|
|
5
7
|
// This is a machine-enforced contract, not a claim inferred from a script name.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
+
// Runtime validation checks effective compiler membership, configuration and
|
|
9
|
+
// toolchain identity. Supported discovery certificates still validate those
|
|
10
|
+
// filesystem queries; caller context is independently compared at the decision.
|
|
8
11
|
export function qualifyTypeScript(root,selected,discovered={}) {
|
|
12
|
+
if(selected.qualification===directQualification){
|
|
13
|
+
const issues=[];
|
|
14
|
+
try{
|
|
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
|
+
}catch(e){issues.push(e.code||'direct compiler prerequisites unavailable');}
|
|
19
|
+
if(!selected.inputs?.includes('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}')||
|
|
20
|
+
!selected.inputs?.includes('package.json')||!selected.inputs?.includes('tsconfig.json')||
|
|
21
|
+
!selected.installedInputs?.includes('node_modules/**'))issues.push('direct compiler source/config/installed inputs incomplete');
|
|
22
|
+
if(!selected.environment?.executableIdentity||
|
|
23
|
+
!directEnvironment.variables.every(key=>selected.environment?.variables?.includes(key))||
|
|
24
|
+
!directEnvironment.prefixes.every(key=>selected.environment?.prefixes?.includes(key)))
|
|
25
|
+
issues.push('direct compiler execution context incomplete');
|
|
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&&
|
|
28
|
+
JSON.stringify(argv.slice(3))===JSON.stringify(runArgv(checkRun(selected)))))issues.push('direct compiler probe missing');
|
|
29
|
+
return {qualified:issues.length===0,issues};
|
|
30
|
+
}
|
|
9
31
|
if(selected.qualification==='pnpm-tsc-v1'){
|
|
10
32
|
const issues=[];
|
|
11
33
|
const workspace=selected.workspace||'.';
|
|
12
|
-
const script=selected.script||selected.command?.at(-1);
|
|
13
34
|
try{
|
|
14
35
|
if(discovered.issue)throw Error(discovered.issue);
|
|
15
36
|
const contract=discovered.workspace;
|
|
16
37
|
if(!contract)throw Error('pnpm TypeScript input discovery unavailable');
|
|
17
38
|
issues.push(...contract.limitations);
|
|
18
|
-
|
|
39
|
+
// The arguments are checked against the script by the contract itself.
|
|
19
40
|
if(path.resolve(root,selected.cwd||'.')!==path.join(root,contract.workspace.dir)||
|
|
20
41
|
selected.command?.[0]!==process.execPath||
|
|
21
|
-
selected.command?.[1]!==contract.tsc
|
|
22
|
-
JSON.stringify(selected.command.slice(2))!==JSON.stringify(declared))
|
|
42
|
+
selected.command?.[1]!==contract.tsc)
|
|
23
43
|
issues.push('standalone TypeScript invocation changed');
|
|
24
44
|
}catch(e){issues.push(e.message);}
|
|
25
45
|
if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='pnpm-typecheck-probe.mjs'))
|
|
@@ -50,47 +70,7 @@ export function qualifyTypeScript(root,selected,discovered={}) {
|
|
|
50
70
|
return {qualified:issues.length===0,issues};
|
|
51
71
|
}
|
|
52
72
|
if(selected.qualification!=='typescript-noemit-v1')return null;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
catch{issues.push('package manifest unavailable');}
|
|
57
|
-
if(!selected.script||pkg?.scripts?.[selected.script]?.trim()!=='tsc --noEmit'||
|
|
58
|
-
pkg?.scripts?.['pre'+selected.script]||pkg?.scripts?.['post'+selected.script])
|
|
59
|
-
issues.push('typecheck script is not the supported standalone tsc --noEmit invocation');
|
|
60
|
-
if(selected.command?.length!==3||selected.command[1]!=='run'||
|
|
61
|
-
selected.command[2]!==selected.script)
|
|
62
|
-
issues.push('package-manager invocation differs from the discovered script');
|
|
63
|
-
try{const npm=fs.realpathSync(selected.command[0]);
|
|
64
|
-
const supported=process.platform==='win32'?path.basename(npm).toLowerCase()==='npm.cmd'&&
|
|
65
|
-
fs.existsSync(path.join(path.dirname(npm),'node_modules','npm','bin','npm-cli.js')):
|
|
66
|
-
path.basename(npm)==='npm-cli.js'&&
|
|
67
|
-
path.basename(path.dirname(path.dirname(npm)))==='npm';
|
|
68
|
-
if(!supported)
|
|
69
|
-
issues.push('npm executable layout is not supported');}
|
|
70
|
-
catch{issues.push('npm executable unavailable');}
|
|
71
|
-
if(!fs.existsSync(path.join(root,'tsconfig.json')))
|
|
72
|
-
issues.push('tsconfig.json is missing');
|
|
73
|
-
if(!fs.existsSync(path.join(root,'node_modules','typescript','package.json'))||
|
|
74
|
-
!fs.existsSync(path.join(root,'node_modules','.bin',
|
|
75
|
-
process.platform==='win32'?'tsc.cmd':'tsc')))
|
|
76
|
-
issues.push('local TypeScript installation is missing');
|
|
77
|
-
if(!selected.inputs?.includes('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}')||
|
|
78
|
-
!selected.inputs?.includes('package.json')||
|
|
79
|
-
!selected.inputs?.includes('tsconfig.json'))
|
|
80
|
-
issues.push('TypeScript source/config file-set declaration is incomplete');
|
|
81
|
-
if(!selected.installedInputs?.includes('node_modules/**'))
|
|
82
|
-
issues.push('installed package contents and membership are not declared');
|
|
83
|
-
if(!selected.environment?.executableIdentity||
|
|
84
|
-
!['node','npm'].every(name=>selected.environment?.pathExecutables?.includes(name))||
|
|
85
|
-
!['NODE_OPTIONS','NODE_PATH','HOME','BASH_ENV','ENV'].every(name=>
|
|
86
|
-
selected.environment?.variables?.includes(name))||
|
|
87
|
-
!['npm_config_','DYLD_','TSGO_'].every(name=>
|
|
88
|
-
selected.environment?.prefixes?.includes(name)))
|
|
89
|
-
issues.push('caller toolchain or execution context declaration is incomplete');
|
|
90
|
-
if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='typescript-contract-probe.mjs'&&
|
|
91
|
-
argv[2]&&fs.existsSync(argv[2])&&
|
|
92
|
-
realObservedPath(argv[2])===root&&argv[3]===selected.script&&
|
|
93
|
-
argv[4]===selected.command[0]))
|
|
94
|
-
issues.push('TypeScript input and toolchain probe is missing');
|
|
95
|
-
return {qualified:issues.length===0,issues};
|
|
73
|
+
// Retired automatic launcher contract: npm's notifier consumes unobserved
|
|
74
|
+
// remote/cache/time state. A successful execution is still an outcome.
|
|
75
|
+
return {qualified:false,issues:['npm launcher applicability is unqualified: update notification uses unobserved remote/cache/time inputs; run the npm script when needed']};
|
|
96
76
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
// Up to 1000 packages: every workspace node_modules is an installed input of
|
|
9
|
+
// a root check, so all of them are listed.
|
|
10
|
+
export function workspaceManifests(root,patterns,limit=1000) {
|
|
11
|
+
const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
12
|
+
'-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
|
|
13
|
+
maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
|
|
14
|
+
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
|
|
15
|
+
if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
|
|
16
|
+
// `!pattern` entries exclude, as in pnpm-workspace.yaml.
|
|
17
|
+
const included=patternMatcher(patterns);
|
|
18
|
+
const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
|
|
19
|
+
const dir=path.posix.dirname(file);
|
|
20
|
+
return dir!=='.'&&!dir.split('/').includes('node_modules')&&included(dir);
|
|
21
|
+
}).sort();
|
|
22
|
+
if(matched.length>limit)throw Error(`more than ${limit} workspace packages found; select checks explicitly`);
|
|
23
|
+
return matched.map(file=>({file,pkg:JSON.parse(fs.readFileSync(path.join(root,file),'utf8'))}));
|
|
24
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Read-only applicability probe. Its output is one digest or a fixed reason;
|
|
3
3
|
// source, configuration, environment values, and command output are not logged.
|
|
4
|
+
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
4
5
|
import {compilerFiles} from './typescript-list.mjs';
|
|
5
6
|
import {contextOnly,probeResult} from './probe-result.mjs';
|
|
6
7
|
import fs from 'node:fs';
|
|
@@ -79,7 +80,7 @@ try{
|
|
|
79
80
|
const tsc=yarnLocalTsc(root);
|
|
80
81
|
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(path.join(root,'node_modules/typescript'),cwd):null;
|
|
81
82
|
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'-p','.','--listFilesOnly'],
|
|
82
|
-
{cwd,env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
|
|
83
|
+
{cwd,env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
|
|
83
84
|
stdio:['ignore','pipe','pipe']});
|
|
84
85
|
if(listed.error||listed.signal||listed.status!==0)fail('typescript-input-list-unavailable');
|
|
85
86
|
const inputs=listed.stdout.trim().split(/\r?\n/).filter(Boolean)
|