@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +806 -0
  2. package/README.md +157 -99
  3. package/bin/redue.mjs +267 -61
  4. package/docs/README.md +3 -1
  5. package/docs/user/agents.md +333 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +219 -0
  8. package/docs/user/getting-started.md +49 -7
  9. package/docs/user/json.md +4 -2
  10. package/docs/user/node-script.md +177 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/sandbox.md +312 -0
  14. package/docs/user/shell.md +3 -3
  15. package/docs/user/supported-projects.md +69 -23
  16. package/docs/user/tests-direct.md +143 -0
  17. package/package.json +4 -3
  18. package/src/agent-integration.mjs +467 -0
  19. package/src/agent-policy.md +28 -0
  20. package/src/cached-state.mjs +24 -1
  21. package/src/claude-hook.mjs +616 -0
  22. package/src/cli.mjs +449 -54
  23. package/src/daemon.mjs +442 -93
  24. package/src/decision-profile.mjs +15 -1
  25. package/src/decision-validation.mjs +12 -1
  26. package/src/declared-plan.mjs +38 -6
  27. package/src/direct-run-certificate.mjs +254 -0
  28. package/src/direct-typescript-probe.mjs +27 -0
  29. package/src/direct-typescript.mjs +205 -0
  30. package/src/glob.mjs +8 -0
  31. package/src/history-worker.mjs +21 -13
  32. package/src/index.mjs +108 -21
  33. package/src/installed-inputs.mjs +7 -1
  34. package/src/installed-observation.mjs +23 -0
  35. package/src/landlock.py +527 -0
  36. package/src/linux-inotify.py +39 -10
  37. package/src/node-onboarding.mjs +203 -54
  38. package/src/node-script.mjs +406 -0
  39. package/src/npm-integrity.mjs +124 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npmrc.mjs +16 -0
  42. package/src/observation.mjs +34 -12
  43. package/src/path-identity.mjs +8 -0
  44. package/src/plan-worker.mjs +17 -17
  45. package/src/pnpm-inputs.mjs +122 -57
  46. package/src/pnpm-shim-templates.json +180 -0
  47. package/src/pnpm-shims.mjs +54 -0
  48. package/src/pnpm-typecheck-probe.mjs +11 -6
  49. package/src/presentation.mjs +13 -4
  50. package/src/probe-deadline.mjs +37 -0
  51. package/src/probe-result.mjs +7 -3
  52. package/src/project-location.mjs +34 -0
  53. package/src/python-onboarding.mjs +89 -0
  54. package/src/run-recorder.cjs +91 -0
  55. package/src/sandbox.mjs +653 -0
  56. package/src/setup.mjs +205 -0
  57. package/src/stats.mjs +72 -0
  58. package/src/test-direct.mjs +608 -0
  59. package/src/test-recorder.cjs +435 -0
  60. package/src/typescript-contract-probe.mjs +4 -187
  61. package/src/typescript-input-facts.mjs +92 -0
  62. package/src/typescript-invocation.mjs +92 -0
  63. package/src/typescript-list.mjs +118 -13
  64. package/src/typescript-qualification.mjs +29 -49
  65. package/src/workspace-manifests.mjs +24 -0
  66. package/src/yarn-workspace-inputs.mjs +3 -0
  67. 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
+ }
@@ -5,55 +5,132 @@ 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,...options.args).sort();
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
- if(kind==='realpath')return fs.realpathSync(file);
18
+ // How many hard links a file has: a link added elsewhere changes it.
19
+ if(kind==='linkCount')return fs.lstatSync(file).nlink;
20
+ // The compiler resolves through realpathSync.native outside Windows.
21
+ if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
17
22
  if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
18
23
  .filter(entry=>entry.isDirectory()||entry.isSymbolicLink()&&
19
24
  fs.statSync(path.join(file,entry.name)).isDirectory()).map(entry=>entry.name).sort();
20
25
  // Directory entries include names/types, including newly created candidates.
21
26
  if(kind==='entries')return fs.readdirSync(file,{withFileTypes:true})
22
27
  .map(entry=>[entry.name,entry.isDirectory()?'d':entry.isSymbolicLink()?'l':'f']).sort();
28
+ // A compiler directory listing: subdirectories and files with a requested
29
+ // extension, classifying links by their targets as matchFiles does.
30
+ if(kind==='listing'){
31
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
32
+ return fs.readdirSync(file,{withFileTypes:true}).flatMap(entry=>{
33
+ let type=entry;
34
+ if(entry.isSymbolicLink())try{type=fs.statSync(path.join(file,entry.name));}catch{return [];}
35
+ if(type.isDirectory())return [[entry.name,'d']];
36
+ if(type.isFile()&&(!suffixes||suffixes.some(value=>entry.name.toLowerCase().endsWith(value))))
37
+ return [[entry.name,'f']];
38
+ return [];
39
+ }).sort();
40
+ }
23
41
  throw Error('unknown compiler filesystem question');
24
42
  }catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
25
43
  }
44
+ // Null when every run fact answers as it did during the run, otherwise the
45
+ // first path that does not. Asked in-process; nothing is launched.
46
+ export function runFactChange(facts) {
47
+ if(!Array.isArray(facts))return 'an unreadable run fact';
48
+ for(const fact of facts){
49
+ if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries','linkCount'].includes(fact[0])||
50
+ typeof fact[1]!=='string')return 'an unreadable run fact';
51
+ const [kind,file,answer]=fact;
52
+ let now;try{now=queryAnswer(kind,file);}catch{return file;}
53
+ // A test run's directory listings are arrays.
54
+ if(kind==='entries'?JSON.stringify(now)!==JSON.stringify(answer):now!==answer)return file;
55
+ }
56
+ return null;
57
+ }
58
+ // Why a receipt's run facts no longer hold, or null when they all do.
59
+ export function runFactReason(facts) {
60
+ const changed=runFactChange(facts);
61
+ return changed&&`a lookup the last run made answers differently now: ${changed}`;
62
+ }
26
63
  export function queriesMatch(queries) {
27
64
  if(!queries?.length)return false;
28
65
  return queries.every(([kind,file,answer,options])=>
29
66
  JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
30
67
  }
31
- export function compilerFiles(tsRoot,cwd) {
68
+ // Why a recipe built on TypeScript's compiler API cannot use the TypeScript
69
+ // installed at `tsRoot`, or null: TypeScript 7 is a native compiler and
70
+ // ships no such API.
71
+ export function compilerApiIssue(tsRoot) {
72
+ let version=null;try{version=JSON.parse(fs.readFileSync(path.join(tsRoot,'package.json'),'utf8')).version;}catch{}
73
+ return Number(String(version).split('.')[0])>=7?
74
+ `TypeScript ${version} is a native compiler without the compiler API this recipe reads; REDUE does not support TypeScript 7 yet`:null;
75
+ }
76
+ export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
32
77
  const require=createRequire(import.meta.url),ts=require(tsRoot);
33
78
  if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
34
79
  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,[kind,full,answer]);return answer;
80
+ const remember=(kind,file,options)=>{
81
+ const full=path.resolve(file),answer=queryAnswer(kind,full,options);
82
+ queries.set(kind+'\0'+full+(options?JSON.stringify(options):''),
83
+ options?[kind,full,answer,options]:[kind,full,answer]);return answer;
38
84
  };
39
85
  const readFile=file=>{remember('readFile',file);return ts.sys.readFile(file);};
40
86
  const fileExists=file=>{remember('fileExists',file);return ts.sys.fileExists(file);};
41
87
  const directoryExists=file=>{remember('directoryExists',file);return ts.sys.directoryExists(file);};
42
88
  const getDirectories=file=>{remember('directories',file);return ts.sys.getDirectories(file);};
43
89
  const readDirectory=(dir,...args)=>{
44
- const result=ts.sys.readDirectory(dir,...args);
45
- const full=path.resolve(dir),options={tsRoot,args};
90
+ // Reviewed compilers enumerate each visited directory through fs.readdirSync.
91
+ // Record those listings as ordinary questions so a validator without the
92
+ // compiler API can replay this answer; the listing itself stays authoritative.
93
+ const listed=[],readdirSync=fs.readdirSync;
94
+ fs.readdirSync=function(file,...rest){listed.push(path.resolve(String(file)));
95
+ return readdirSync.call(this,file,...rest);};
96
+ let result;try{result=ts.sys.readDirectory(dir,...args);}finally{fs.readdirSync=readdirSync;}
97
+ // JSON turns undefined array entries into null. TypeScript distinguishes
98
+ // them (an omitted exclude list is not a null list), so retain that fact.
99
+ const full=path.resolve(dir),options={tsRoot,args,argsVersion:1,
100
+ undefinedArguments:args.flatMap((value,i)=>value===undefined?[i]:[])};
101
+ const visited=new Set(listed);
102
+ if(typeof ts.getFileMatcherPatterns==='function'&&
103
+ result.every(file=>visited.has(path.dirname(path.resolve(file)))))try{
104
+ // Base paths and symlinked directories reach matchFiles through realpath
105
+ // deduplication without a listing; a retargeted link changes this answer.
106
+ for(const base of ts.getFileMatcherPatterns(full,args[1],args[2],
107
+ ts.sys.useCaseSensitiveFileNames,process.cwd()).basePaths)remember('realpath',base);
108
+ const extensions=Array.isArray(args[0])?args[0]:null;
109
+ for(const directory of visited){
110
+ // matchFiles also resolves every visited directory to detect cycles.
111
+ remember('realpath',directory);
112
+ remember('listing',directory,{extensions});
113
+ let entries=[];
114
+ try{entries=fs.readdirSync(directory,{withFileTypes:true});}
115
+ catch(error){if(!['ENOENT','ENOTDIR'].includes(error.code))throw error;}
116
+ for(const entry of entries)
117
+ if(entry.isSymbolicLink())remember('realpath',path.join(directory,entry.name));
118
+ }
119
+ options.covered=1;
120
+ }catch{/* uncovered listings are replayed only through the compiler API */}
46
121
  queries.set('readDirectory\0'+full+JSON.stringify(args),
47
122
  ['readDirectory',full,[...result].sort(),options]);
48
123
  return result;
49
124
  };
50
- const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,'tsconfig.json'),{},
51
- {...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,
125
+ const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
126
+ // `extends` of a package config resolves through realpath too.
127
+ const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,config),{},
128
+ {...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
52
129
  onUnRecoverableConfigFileDiagnostic(){}});
53
130
  if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
54
131
  const host=ts.createCompilerHost(parsed.options);
55
- Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,
56
- realpath(file){remember('realpath',file);return ts.sys.realpath(file);},
132
+ Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,readDirectory,
133
+ realpath,
57
134
  getSourceFile(file,languageVersion,onError){
58
135
  const text=readFile(file);return text===undefined?undefined:
59
136
  ts.createSourceFile(file,text,languageVersion);
@@ -64,5 +141,33 @@ export function compilerFiles(tsRoot,cwd) {
64
141
  // Stable answers are necessary even at first capture; a concurrent edit
65
142
  // cannot become the basis for a reusable discovery result.
66
143
  const facts=[...queries.values()];
67
- return {files,queries:queriesMatch(facts)?facts:null};
144
+ // A certificate must cover every program file. Referenced configurations
145
+ // are parsed through this host, so their reads and listings are certified.
146
+ const complete=files.every(file=>queries.has('readFile\0'+file));
147
+ return {files,queries:complete&&queriesMatch(facts)?facts:null};
148
+ }
149
+ // Identity of a read file and, for a link, of its target. Writes through any
150
+ // alias change ctime. A file changed within the last 2 s has no identity, so a
151
+ // coarse timestamp cannot hide a second write in the same tick (git's racy rule).
152
+ export function fileIdentity(file,now=Date.now()) {
153
+ try{const link=fs.lstatSync(file,{bigint:true}),target=link.isSymbolicLink()?fs.statSync(file,{bigint:true}):link;
154
+ if(racyUntil(link)>=now||racyUntil(target)>=now)return null;
155
+ const fields=st=>identityFields.map(key=>String(st[key]));
156
+ return JSON.stringify([fields(target),target===link?null:[fs.realpathSync(file),...fields(link)]]);}
157
+ catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
158
+ }
159
+ // In-process replay of a certificate. Covered directory listings are checked
160
+ // through their recorded listings instead of loading the compiler API. A read
161
+ // file is re-hashed until a match records its identity, taken before the read,
162
+ // and again whenever that identity changes.
163
+ export function queriesHold(queries,identities=new Map()) {
164
+ if(!queries?.length)return false;
165
+ return queries.every(([kind,file,answer,options])=>{
166
+ if(kind==='readDirectory')return options?.covered===1;
167
+ const identity=kind==='readFile'?fileIdentity(file):null;
168
+ if(identity&&identities.get(file)===identity)return true;
169
+ const held=JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer);
170
+ if(kind==='readFile'){if(held&&identity)identities.set(file,identity);else identities.delete(file);}
171
+ return held;
172
+ });
68
173
  }
@@ -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
- // The read-only runtime probe separately checks the effective TypeScript file
7
- // list, config graph, npm toolchain and caller context on every sync/run.
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
- const declared=contract.workspace.pkg.scripts[script].trim().split(/\s+/).slice(1);
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
- const issues=[];
54
- let pkg;
55
- try{pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));}
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
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {execFileSync} from 'node:child_process';
4
4
  import {createRequire} from 'node:module';
5
+ import {compilerApiIssue} from './typescript-list.mjs';
5
6
  import micromatch from './glob.mjs';
6
7
  import {withinPath as within,realObservedPath} from './path-identity.mjs';
7
8
 
@@ -58,6 +59,8 @@ export function yarnWorkspacePackages(root){
58
59
  }
59
60
 
60
61
  function parsedTypeScript(root,dir){
62
+ const apiIssue=compilerApiIssue(path.join(root,'node_modules','typescript'));
63
+ if(apiIssue)throw Error(apiIssue);
61
64
  const require=createRequire(path.join(root,'package.json'));
62
65
  const ts=require(path.join(root,'node_modules','typescript'));
63
66
  const config=path.join(root,dir,'tsconfig.json'),reads=new Set();
@@ -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)