@redue/cli 0.0.0-stage → 0.1.0-alpha.11

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 (66) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +180 -0
  3. package/README.md +162 -2
  4. package/bin/redue.mjs +371 -0
  5. package/bin/vstate.mjs +3 -0
  6. package/docs/README.md +21 -0
  7. package/docs/user/agents.md +253 -0
  8. package/docs/user/configuration.md +60 -0
  9. package/docs/user/direct-typescript.md +114 -0
  10. package/docs/user/getting-started.md +148 -0
  11. package/docs/user/json.md +59 -0
  12. package/docs/user/npm-launcher-notice.md +42 -0
  13. package/docs/user/pilot.md +145 -0
  14. package/docs/user/receipt-selection-notice.md +60 -0
  15. package/docs/user/shell.md +25 -0
  16. package/docs/user/supported-projects.md +90 -0
  17. package/examples/redue.config.json +8 -0
  18. package/package.json +47 -4
  19. package/src/agent-integration.mjs +294 -0
  20. package/src/agent-policy.md +70 -0
  21. package/src/cached-state.mjs +42 -0
  22. package/src/claude-hook.mjs +115 -0
  23. package/src/cli.mjs +499 -0
  24. package/src/control-endpoint.mjs +11 -0
  25. package/src/daemon.mjs +1236 -0
  26. package/src/decision-profile.mjs +32 -0
  27. package/src/decision-validation.mjs +51 -0
  28. package/src/declared-plan.mjs +125 -0
  29. package/src/direct-run-certificate.mjs +185 -0
  30. package/src/direct-typescript-probe.mjs +22 -0
  31. package/src/direct-typescript.mjs +109 -0
  32. package/src/executable-lookup.mjs +18 -0
  33. package/src/glob.mjs +9 -0
  34. package/src/history-worker.mjs +26 -0
  35. package/src/index.mjs +306 -0
  36. package/src/installed-inputs.mjs +110 -0
  37. package/src/linux-inotify.py +228 -0
  38. package/src/linux-observer.mjs +79 -0
  39. package/src/node-onboarding.mjs +274 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npm-typecheck-probe.mjs +120 -0
  42. package/src/observation.mjs +59 -0
  43. package/src/owned-state.mjs +32 -0
  44. package/src/path-identity.mjs +43 -0
  45. package/src/plan-worker.mjs +140 -0
  46. package/src/plan.mjs +10 -0
  47. package/src/platform-observation.mjs +17 -0
  48. package/src/platform-state.mjs +14 -0
  49. package/src/pnpm-inputs.mjs +355 -0
  50. package/src/pnpm-typecheck-probe.mjs +47 -0
  51. package/src/presentation.mjs +53 -0
  52. package/src/probe-result.mjs +25 -0
  53. package/src/process-liveness.mjs +11 -0
  54. package/src/project-location.mjs +34 -0
  55. package/src/run-lock.mjs +77 -0
  56. package/src/run-recorder.cjs +78 -0
  57. package/src/state-store.mjs +73 -0
  58. package/src/toolchain-probe.mjs +21 -0
  59. package/src/typescript-contract-probe.mjs +5 -0
  60. package/src/typescript-input-facts.mjs +68 -0
  61. package/src/typescript-list.mjs +141 -0
  62. package/src/typescript-qualification.mjs +77 -0
  63. package/src/windows-command.mjs +16 -0
  64. package/src/windows-process.mjs +15 -0
  65. package/src/yarn-workspace-inputs.mjs +148 -0
  66. package/src/yarn-workspace-typecheck-probe.mjs +107 -0
@@ -0,0 +1,25 @@
1
+ // Internal supported-provider protocol. Default stdout is unchanged, so
2
+ // ordinary run checkpoints keep the same probe/result identity. The daemon
3
+ // may reuse a complete result only after revalidating its input key AND this
4
+ // independently checked execution/toolchain context. No TTL grants trust.
5
+ import {deflateSync} from 'node:zlib';
6
+ export function contextOnly(context) {
7
+ if(process.argv.includes('--redue-context')){
8
+ process.stdout.write(context);return true;
9
+ }
10
+ return false;
11
+ }
12
+ // A layout lets a recorded run rebuild the output after a content-only edit.
13
+ export function probeResult(output,context,queries=null,layout=null) {
14
+ if(!process.argv.includes('--redue-checkpoint')){process.stdout.write(output);return;}
15
+ const raw=queries?JSON.stringify(queries):null;
16
+ const queryData=raw&&Buffer.byteLength(raw)<=8*1024*1024?deflateSync(raw).toString('base64'):null;
17
+ let value=JSON.stringify({output,context,queryData,
18
+ ...(layout?{layoutData:deflateSync(JSON.stringify(layout)).toString('base64')}:{})});
19
+ // The layout is dropped first: it only shortens the run after an edit.
20
+ if(Buffer.byteLength(value)>512*1024)value=JSON.stringify({output,context,queryData});
21
+ // Optimization metadata must not overflow the ordinary probe channel.
22
+ // Larger contracts keep the normal complete probe, without result reuse.
23
+ if(Buffer.byteLength(value)>512*1024)value=JSON.stringify({output,context,queries:null});
24
+ process.stdout.write(value);
25
+ }
@@ -0,0 +1,11 @@
1
+ import fs from 'node:fs';
2
+
3
+ export function processAlive(pid) {
4
+ if(!Number.isSafeInteger(pid)||pid<1)return true;
5
+ if(process.platform==='linux')try{
6
+ const stat=fs.readFileSync(`/proc/${pid}/stat`,'utf8');
7
+ if(['Z','X'].includes(stat.slice(stat.lastIndexOf(')')+2)[0]))return false;
8
+ }catch(e){if(e.code!=='ENOENT'&&e.code!=='ESRCH')return true;}
9
+ try{process.kill(pid,0);return true;}
10
+ catch(e){return e.code!=='ESRCH';}
11
+ }
@@ -0,0 +1,34 @@
1
+ // Shared project selection only; evidence and caller-context semantics stay in the kernel.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import {createHash} from 'node:crypto';
6
+ import {realObservedPath} from './path-identity.mjs';
7
+ import {stateIdentity,userStateBase} from './platform-state.mjs';
8
+
9
+ export function discoverConfig(cwd=process.cwd()) {
10
+ let dir=path.resolve(cwd);const found=[];
11
+ for(;;){
12
+ for(const name of ['redue.config.json','vstate.config.json']) {
13
+ const file=path.join(dir,name);
14
+ if(fs.existsSync(file))found.push(file);
15
+ }
16
+ if(fs.existsSync(path.join(dir,'.git'))||path.dirname(dir)===dir)break;
17
+ dir=path.dirname(dir);
18
+ }
19
+ if(found.length>1)throw Error('multiple REDUE configurations apply; select the intended one with --config FILE');
20
+ return found[0]||path.resolve(cwd,'redue.config.json');
21
+ }
22
+ export function canonicalConfig(configFile) {
23
+ return path.join(realObservedPath(path.dirname(configFile)),path.basename(configFile));
24
+ }
25
+ export function projectState(root,configFile) {
26
+ return path.join(userStateBase(process.platform,process.env,os.homedir()),'vstate',
27
+ `vstate-${createHash('sha256').update(stateIdentity(root)+'\0'+stateIdentity(canonicalConfig(configFile))).digest('hex').slice(0,16)}`);
28
+ }
29
+ export function readProject(configFile) {
30
+ const config=JSON.parse(fs.readFileSync(configFile,'utf8'));
31
+ if(config.schema!==1||!Array.isArray(config.checks)||!config.checks.length)
32
+ throw Error('config needs schema 1 and at least one check');
33
+ return {config,root:realObservedPath(path.resolve(path.dirname(configFile),config.root||'.'))};
34
+ }
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {processAlive} from './process-liveness.mjs';
5
+
6
+ const alive=processAlive;
7
+ const write=(file,value)=>{
8
+ const temp=file+`.${process.pid}.tmp`;
9
+ fs.writeFileSync(temp,JSON.stringify(value),{mode:0o600});
10
+ fs.renameSync(temp,file);
11
+ };
12
+
13
+ export function inspectRunLock(state) {
14
+ const lock=path.join(state,'run.lock');
15
+ if(!fs.existsSync(lock))return {state:'absent'};
16
+ let owner;
17
+ try{owner=JSON.parse(fs.readFileSync(path.join(lock,'owner.json'),'utf8'));}
18
+ catch{return {state:'uncertain',reason:'lock owner record missing or unreadable'};}
19
+ if(owner.schema!==1||!owner.token||!Number.isSafeInteger(owner.pid))
20
+ return {state:'uncertain',reason:'lock owner record invalid'};
21
+ if(alive(owner.pid))return {state:'live',reason:'wrapper process exists',owner};
22
+ if(owner.phase==='running'&&Number.isSafeInteger(owner.childPid)&&alive(owner.childPid))
23
+ return {state:'uncertain',reason:'recorded check process is still running',owner};
24
+ if(['launching','running','finished'].includes(owner.phase))
25
+ return {state:'uncertain',reason:'a descendant check process may survive; operator review required',owner};
26
+ if(owner.phase!=='prepared')
27
+ return {state:'uncertain',reason:'unknown wrapper phase',owner};
28
+ return {state:'abandoned',reason:'wrapper died before child launch',owner};
29
+ }
30
+
31
+ function reclaim(state,confirmed=false){
32
+ const lock=path.join(state,'run.lock'),guard=path.join(state,'run-recovery.lock');
33
+ fs.mkdirSync(guard);
34
+ try{
35
+ const status=inspectRunLock(state);
36
+ if(status.state!=='abandoned'){
37
+ const owner=status.owner;
38
+ const candidate=confirmed&&status.state==='uncertain'&&owner&&
39
+ !alive(owner.pid)&&(!Number.isSafeInteger(owner.childPid)||!alive(owner.childPid));
40
+ if(!candidate)throw Error(`run lock ${status.state}: ${status.reason}; inspect ${lock}`);
41
+ }
42
+ const entries=fs.readdirSync(lock);
43
+ if(entries.length!==1||entries[0]!=='owner.json')
44
+ throw Error('run lock has unexpected files; operator inspection required');
45
+ const quarantined=path.join(state,`abandoned-run-${randomUUID()}`);
46
+ fs.renameSync(lock,quarantined);
47
+ fs.unlinkSync(path.join(quarantined,'owner.json'));fs.rmdirSync(quarantined);
48
+ }finally{fs.rmdirSync(guard);}
49
+ }
50
+
51
+ export function recoverRunLock(state,confirmed=false){
52
+ if(!confirmed)throw Error('explicit --confirm-check-stopped required');
53
+ reclaim(state,true);
54
+ return {recovered:true};
55
+ }
56
+
57
+ export function acquireRunLock(state) {
58
+ const lock=path.join(state,'run.lock');
59
+ const token=randomUUID();
60
+ try{fs.mkdirSync(lock);}
61
+ catch(e){
62
+ if(e.code!=='EEXIST')throw e;
63
+ reclaim(state);
64
+ fs.mkdirSync(lock);
65
+ }
66
+ const owner={schema:1,token,pid:process.pid,phase:'prepared',childPid:null,
67
+ startedAt:Date.now()};
68
+ const file=path.join(lock,'owner.json');write(file,owner);
69
+ return {
70
+ phase(value,childPid=null){owner.phase=value;owner.childPid=childPid;write(file,owner);},
71
+ release(){
72
+ const current=JSON.parse(fs.readFileSync(file,'utf8'));
73
+ if(current.token!==token)throw Error('run lock ownership changed; refusing removal');
74
+ fs.unlinkSync(file);fs.rmdirSync(lock);
75
+ }
76
+ };
77
+ }
@@ -0,0 +1,78 @@
1
+ // Preload for a direct compiler run: node --require <this> <tsc> ... with a
2
+ // pipe on fd 3. Records the answers the compiler received from node:fs, in the
3
+ // terms of queryAnswer, and writes them to fd 3 at exit. It never changes an
4
+ // answer, an output or the exit code; any failure only makes the record unusable.
5
+ 'use strict';
6
+ const fs=require('node:fs'),path=require('node:path'),{createHash}=require('node:crypto');
7
+ const original={readFileSync:fs.readFileSync,statSync:fs.statSync,readdirSync:fs.readdirSync,
8
+ realpathSync:fs.realpathSync,native:fs.realpathSync.native,writeSync:fs.writeSync,openSync:fs.openSync};
9
+ const real=file=>(process.platform==='win32'?original.native:original.realpathSync)(file);
10
+ const facts=new Map(),compilerReads=new Set();let unsupported=null,conflict=null,main=null,compilerRoot=null;
11
+ try{main=real(process.argv[1]);compilerRoot=path.dirname(path.dirname(main));}
12
+ catch{unsupported='main module unavailable';}
13
+ const kindOf=st=>st.isFile()?'f':st.isDirectory()?'d':'o';
14
+ const missing=error=>['ENOENT','ENOTDIR'].includes(error?.code);
15
+ // The compiler tree (lib/<entry>/../..) is covered by the daemon's toolchain
16
+ // identity, not by this record. tsc also stats a case-swapped copy of its own path.
17
+ const inCompiler=full=>full===compilerRoot||full.startsWith(compilerRoot+path.sep)||
18
+ full!==main&&full.toLowerCase()===main.toLowerCase();
19
+ function observe(kind,call,answer) {
20
+ return function(file,...rest){
21
+ let value,full=null;
22
+ // Compiler-tree reads are named only: which lib files a program loads.
23
+ try{if(typeof file==='string'){full=path.resolve(file);
24
+ if(inCompiler(full)){if(kind==='read')compilerReads.add(full);full=null;}}
25
+ else unsupported??=`${kind} of a non-string path`;}
26
+ catch{unsupported??='recorder failure';}
27
+ if(full===null)return call.call(this,file,...rest);
28
+ let recorded;
29
+ try{value=call.call(this,file,...rest);}
30
+ catch(error){recorded=missing(error)?null:{error:error?.code||'error'};note(kind,full,recorded);throw error;}
31
+ try{recorded=answer(value,file);}catch{unsupported??='recorder failure';return value;}
32
+ note(kind,full,recorded);
33
+ return value;
34
+ };
35
+ }
36
+ function note(kind,full,answer) {
37
+ try{const key=kind+'\0'+full,value=JSON.stringify(answer),seen=facts.get(key);
38
+ if(seen===undefined)facts.set(key,value);else if(seen!==value)conflict??=`${kind} ${full}`;}
39
+ catch{unsupported??='recorder failure';}
40
+ }
41
+ fs.readFileSync=observe('read',original.readFileSync,value=>
42
+ createHash('sha256').update(JSON.stringify(typeof value==='string'?value:value.toString('utf8'))).digest('hex'));
43
+ fs.statSync=observe('stat',original.statSync,value=>value?kindOf(value):null);
44
+ fs.readdirSync=observe('readdir',original.readdirSync,(value,dir)=>value.map(entry=>{
45
+ if(typeof entry==='string')return [entry,'?'];
46
+ if(!entry.isSymbolicLink())return [entry.name,kindOf(entry)];
47
+ let target=null;try{target=kindOf(original.statSync(path.join(dir,entry.name)));}catch{}
48
+ return [entry.name,'l',target];
49
+ }).sort((a,b)=>a[0]<b[0]?-1:a[0]>b[0]?1:0));
50
+ const realpath=observe('realpath',original.realpathSync,value=>value);
51
+ realpath.native=observe('realpath',original.native,value=>value);
52
+ fs.realpathSync=realpath;
53
+ // readFileSync opens through fs.openSync; only a writable open is a write.
54
+ fs.openSync=function(file,flags,...rest){
55
+ if(!(flags===undefined||flags==='r'||flags==='rs'||flags===fs.constants.O_RDONLY))unsupported??='openSync';
56
+ return original.openSync.call(this,file,flags,...rest);
57
+ };
58
+ // Writes, and reads this record cannot answer for, make it unusable. readSync
59
+ // and fstatSync stay open: readFileSync reads through them.
60
+ for(const name of ['writeFileSync','appendFileSync','mkdirSync','unlinkSync','utimesSync',
61
+ 'renameSync','rmSync','watch','watchFile','existsSync','lstatSync','accessSync','opendirSync',
62
+ 'readlinkSync','readFile','stat','lstat','readdir','realpath','access','exists','open','opendir',
63
+ 'readlink','createReadStream']){
64
+ const call=fs[name];
65
+ fs[name]=Object.assign(function(...args){unsupported??=name;return call.apply(this,args);},call);
66
+ }
67
+ for(const name of ['readFile','stat','lstat','readdir','realpath','access','open','opendir','readlink']){
68
+ const call=fs.promises[name];
69
+ fs.promises[name]=function(...args){unsupported??=`promises.${name}`;return call.apply(this,args);};
70
+ }
71
+ process.on('exit',()=>{
72
+ try{
73
+ const buffer=Buffer.from(JSON.stringify({schema:1,node:[real(process.execPath),process.version],main,
74
+ unsupported,conflict,compilerReads:[...compilerReads],facts:[...facts].map(([key,value])=>{const at=key.indexOf('\0');
75
+ return [key.slice(0,at),key.slice(at+1),JSON.parse(value)];})}));
76
+ for(let at=0;at<buffer.length;)at+=original.writeSync(3,buffer,at,buffer.length-at);
77
+ }catch{}
78
+ });
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const STATE_SCHEMA=1;
5
+
6
+ export function atomicJson(file,value){
7
+ const temp=file+`.`+process.pid+`.tmp`;
8
+ fs.writeFileSync(temp,JSON.stringify(value),{mode:0o600});
9
+ // Windows readers and AV can briefly hold the replaced name open. Keep the
10
+ // old complete file until replacement succeeds; never delete it first.
11
+ for(let attempt=0;;attempt++)try{fs.renameSync(temp,file);break;}catch(e){
12
+ if(process.platform!=='win32'||!['EPERM','EACCES','EBUSY'].includes(e.code)||attempt===9)
13
+ throw e;
14
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,10*(attempt+1));
15
+ }
16
+ }
17
+
18
+ export function readReceipts(state){
19
+ let value;
20
+ try{value=JSON.parse(fs.readFileSync(path.join(state,'receipts-v1.json'),'utf8'));}
21
+ catch(e){if(e.code==='ENOENT')return {schema:STATE_SCHEMA,checks:{}};throw e;}
22
+ if(!value||typeof value!=='object'||Array.isArray(value)||value.schema!==STATE_SCHEMA||
23
+ !value.checks||typeof value.checks!=='object'||Array.isArray(value.checks))
24
+ throw Error(`incompatible receipt state in ${state}; inspect or remove only this vstate state directory`);
25
+ return value;
26
+ }
27
+
28
+ // This identity detects replacement of REDUE's owned, atomically written receipt
29
+ // selector. It is not an input-content fingerprint or permission to reuse inputs.
30
+ export function receiptRevision(state){
31
+ // A failed/crashed owned writer may have persisted a new immutable outcome
32
+ // without replacing the selector. Never certify its older selection.
33
+ try {fs.lstatSync(path.join(state,'receipt-selection-pending-v1.json'));
34
+ throw Error('receipt selection update incomplete; inspect the failed run, then record a new execution to recover');
35
+ }catch(error){if(error.code!=='ENOENT')throw error;}
36
+ try {const stat=fs.statSync(path.join(state,'receipts-v1.json'),{bigint:true});
37
+ if(!stat.isFile())throw Error('receipt selector is not a file');
38
+ return JSON.stringify(['dev','ino','size','mtimeNs','ctimeNs'].map(key=>String(stat[key])));
39
+ }catch(error){if(error.code==='ENOENT')return 'absent';throw error;}
40
+ }
41
+
42
+ export function readReceiptSnapshot(state){
43
+ const revision=receiptRevision(state),value=readReceipts(state);
44
+ if(receiptRevision(state)!==revision)
45
+ throw Error('receipt selection changed during read; retry status');
46
+ return {revision,value};
47
+ }
48
+
49
+ export function commitReceipt(state,name,receipt){
50
+ const prior=readReceipts(state),runId=receipt.invocation?.runId;
51
+ if(!/^[0-9a-f-]{36}$/.test(runId||''))throw Error('receipt lacks a valid run ID');
52
+ const runs=path.join(state,'runs-v1');
53
+ fs.mkdirSync(runs,{recursive:true,mode:0o700});
54
+ // The caller holds the state run lock. Leave this marker on any write failure;
55
+ // only a later successful owned commit may clear it. No journal scan guesses
56
+ // which incomplete execution should become the selected evidence.
57
+ const pending=path.join(state,'receipt-selection-pending-v1.json');
58
+ let updates={schema:STATE_SCHEMA,checks:{}};
59
+ try {updates=JSON.parse(fs.readFileSync(pending,'utf8'));
60
+ if(updates?.schema!==STATE_SCHEMA||!updates.checks||typeof updates.checks!=='object'||Array.isArray(updates.checks))
61
+ throw Error('incompatible incomplete receipt update; inspect owned state before recording');
62
+ }catch(error){if(error.code!=='ENOENT')throw error;}
63
+ updates.checks[name]={runId};
64
+ atomicJson(pending,updates);
65
+ // One immutable outcome per invocation. Never overwrite an existing run.
66
+ fs.writeFileSync(path.join(runs,runId+'.json'),
67
+ JSON.stringify({schema:STATE_SCHEMA,check:name,receipt}),{flag:'wx',mode:0o600});
68
+ prior.checks[name]=receipt;
69
+ atomicJson(path.join(state,'receipts-v1.json'),prior);
70
+ delete updates.checks[name];
71
+ if(Object.keys(updates.checks).length)atomicJson(pending,updates);
72
+ else fs.unlinkSync(pending);
73
+ }
@@ -0,0 +1,21 @@
1
+ // Read-only execution identity for explicitly named local tools. Output contains
2
+ // hashes and metadata only; the verifier hashes the output again for its receipt.
3
+ import fs from 'node:fs';
4
+ import {createHash} from 'node:crypto';
5
+ import {fileURLToPath} from 'node:url';
6
+
7
+ const sha=value=>createHash('sha256').update(value).digest('hex');
8
+ function identity(file) {
9
+ const real=fs.realpathSync(file),stat=fs.statSync(real,{bigint:true});
10
+ if(!stat.isFile())throw Error('toolchain identity requires a regular file');
11
+ const facts=[sha(real),...['dev','ino','mode','size','mtimeNs','ctimeNs']
12
+ .map(key=>String(stat[key]))];
13
+ // Hash small scripts/bundles on each probe. For large native executables,
14
+ // version plus identity/metadata keeps synchronized reads inexpensive.
15
+ if(stat.size<=16n*1024n*1024n)facts.push(sha(fs.readFileSync(real)));
16
+ return facts;
17
+ }
18
+ const own=fileURLToPath(import.meta.url);
19
+ process.stdout.write(JSON.stringify({schema:1,nodeVersion:process.version,
20
+ node:identity(process.execPath),probe:identity(own),
21
+ tools:process.argv.slice(2).map(identity)}));
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // Retired npm-launcher interpretation. Do not return even a context-only digest:
3
+ // old receipts and cached compiler certificates cannot qualify this obligation.
4
+ console.error('VSTATE_REASON:npm-launcher-unobserved-inputs');
5
+ process.exitCode=2;
@@ -0,0 +1,68 @@
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 {withinPath as within,realObservedPath} from './path-identity.mjs';
6
+ import {timing} from './decision-profile.mjs';
7
+ const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
8
+ const digestFile=file=>hash(fs.readFileSync(file));
9
+ const fail=code=>{throw Object.assign(Error(code),{code});};
10
+ export function typeScriptInputFacts(root,tsRoot,tsc){
11
+ const configReads=new Set(),visited=new Set();
12
+ let ts;try{ts=require(tsRoot);}catch{}
13
+ if(typeof ts?.getParsedCommandLineOfConfigFile==='function'){
14
+ const host={...ts.sys,readFile(file){configReads.add(path.resolve(file));
15
+ return ts.sys.readFile(file);},onUnRecoverableConfigFileDiagnostic(){}};
16
+ const parseConfig=file=>{
17
+ const absolute=path.resolve(file);
18
+ if(!within(absolute,root))fail('typescript-config-outside-repository');
19
+ if(visited.has(absolute))return;visited.add(absolute);
20
+ if(visited.size>64)fail('typescript-config-graph-too-large');
21
+ const parsed=ts.getParsedCommandLineOfConfigFile(absolute,{},host);
22
+ if(!parsed||parsed.errors?.length)fail('typescript-config-unavailable');
23
+ if(parsed.options?.plugins?.length)fail('typescript-plugin-unsupported');
24
+ if(parsed.options?.incremental||parsed.options?.composite)
25
+ fail('typescript-incremental-unsupported');
26
+ for(const reference of parsed.projectReferences||[])
27
+ parseConfig(reference.path.endsWith('.json')?reference.path:
28
+ path.join(reference.path,'tsconfig.json'));
29
+ };
30
+ parseConfig(path.join(root,'tsconfig.json'));
31
+ }else{
32
+ // TypeScript 7's native compiler does not expose the previous config API.
33
+ // Its CLI reports the effective config; restrict this path to one direct
34
+ // config rather than guessing about extends/reference provenance.
35
+ const config=path.join(root,'tsconfig.json'),raw=fs.readFileSync(config,'utf8');
36
+ if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
37
+ fail('typescript-config-graph-unsupported');
38
+ const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
39
+ env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
40
+ stdio:['ignore','pipe','pipe']});
41
+ if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
42
+ const effective=JSON.parse(shown.stdout);
43
+ if(effective.compilerOptions?.plugins?.length||
44
+ effective.compilerOptions?.incremental||effective.compilerOptions?.composite)
45
+ fail('typescript-config-graph-unsupported');
46
+ configReads.add(config);
47
+ }
48
+ for(const file of configReads)if(!within(realObservedPath(file),root))
49
+ fail('typescript-config-outside-repository');
50
+ const listingStarted=performance.now();
51
+ const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
52
+ const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly'],
53
+ {cwd:root,env:process.env,encoding:'utf8',timeout:process.platform==='win32'?10000:4000,
54
+ maxBuffer:16*1024*1024,
55
+ stdio:['ignore','pipe','pipe']});
56
+ timing('typescript_contract.list_files',listingStarted,{status:listed.status??null,
57
+ error:listed.error?.code||null});
58
+ if(listed.error||listed.signal||listed.status!==0)fail('typescript-input-list-unavailable');
59
+ const files=listed.stdout.trim().split(/\r?\n/).filter(Boolean)
60
+ .map(file=>path.resolve(file));
61
+ if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
62
+ fail('typescript-input-outside-repository');
63
+ // Every hashed configuration must also be a certified question.
64
+ const queries=captured?.queries&&[...configReads].every(file=>
65
+ captured.queries.some(([kind,read])=>kind==='readFile'&&read===file))?captured.queries:null;
66
+ return {configs:[...configReads].sort().map(file=>[file,digestFile(file)]),
67
+ files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries};
68
+ }
@@ -0,0 +1,141 @@
1
+ // TypeScript's compiler host exposes the filesystem questions used to resolve
2
+ // a program. Capture those answers, not runtime application accesses. Reuse is
3
+ // allowed only while every answer and the observed input content still matches.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {createHash} from 'node:crypto';
7
+ import {createRequire} from 'node:module';
8
+ const require=createRequire(import.meta.url);
9
+ const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
10
+ export function queryAnswer(kind,file,options) {
11
+ try{
12
+ if(kind==='readDirectory')return require(options.tsRoot).sys.readDirectory(file,
13
+ ...options.args.map((value,i)=>options.undefinedArguments?.includes(i)?undefined:value)).sort();
14
+ if(kind==='readFile')return hash(fs.readFileSync(file,'utf8'));
15
+ if(kind==='fileExists')return fs.statSync(file).isFile();
16
+ if(kind==='directoryExists')return fs.statSync(file).isDirectory();
17
+ // The compiler resolves through realpathSync.native outside Windows.
18
+ if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
19
+ if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
20
+ .filter(entry=>entry.isDirectory()||entry.isSymbolicLink()&&
21
+ fs.statSync(path.join(file,entry.name)).isDirectory()).map(entry=>entry.name).sort();
22
+ // Directory entries include names/types, including newly created candidates.
23
+ if(kind==='entries')return fs.readdirSync(file,{withFileTypes:true})
24
+ .map(entry=>[entry.name,entry.isDirectory()?'d':entry.isSymbolicLink()?'l':'f']).sort();
25
+ // A compiler directory listing: subdirectories and files with a requested
26
+ // extension, classifying links by their targets as matchFiles does.
27
+ if(kind==='listing'){
28
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
29
+ return fs.readdirSync(file,{withFileTypes:true}).flatMap(entry=>{
30
+ let type=entry;
31
+ if(entry.isSymbolicLink())try{type=fs.statSync(path.join(file,entry.name));}catch{return [];}
32
+ if(type.isDirectory())return [[entry.name,'d']];
33
+ if(type.isFile()&&(!suffixes||suffixes.some(value=>entry.name.toLowerCase().endsWith(value))))
34
+ return [[entry.name,'f']];
35
+ return [];
36
+ }).sort();
37
+ }
38
+ throw Error('unknown compiler filesystem question');
39
+ }catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
40
+ }
41
+ export function queriesMatch(queries) {
42
+ if(!queries?.length)return false;
43
+ return queries.every(([kind,file,answer,options])=>
44
+ JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
45
+ }
46
+ export function compilerFiles(tsRoot,cwd) {
47
+ const require=createRequire(import.meta.url),ts=require(tsRoot);
48
+ if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
49
+ const queries=new Map();
50
+ const remember=(kind,file,options)=>{
51
+ const full=path.resolve(file),answer=queryAnswer(kind,full,options);
52
+ queries.set(kind+'\0'+full+(options?JSON.stringify(options):''),
53
+ options?[kind,full,answer,options]:[kind,full,answer]);return answer;
54
+ };
55
+ const readFile=file=>{remember('readFile',file);return ts.sys.readFile(file);};
56
+ const fileExists=file=>{remember('fileExists',file);return ts.sys.fileExists(file);};
57
+ const directoryExists=file=>{remember('directoryExists',file);return ts.sys.directoryExists(file);};
58
+ const getDirectories=file=>{remember('directories',file);return ts.sys.getDirectories(file);};
59
+ const readDirectory=(dir,...args)=>{
60
+ // Reviewed compilers enumerate each visited directory through fs.readdirSync.
61
+ // Record those listings as ordinary questions so a validator without the
62
+ // compiler API can replay this answer; the listing itself stays authoritative.
63
+ const listed=[],readdirSync=fs.readdirSync;
64
+ fs.readdirSync=function(file,...rest){listed.push(path.resolve(String(file)));
65
+ return readdirSync.call(this,file,...rest);};
66
+ let result;try{result=ts.sys.readDirectory(dir,...args);}finally{fs.readdirSync=readdirSync;}
67
+ // JSON turns undefined array entries into null. TypeScript distinguishes
68
+ // them (an omitted exclude list is not a null list), so retain that fact.
69
+ const full=path.resolve(dir),options={tsRoot,args,argsVersion:1,
70
+ undefinedArguments:args.flatMap((value,i)=>value===undefined?[i]:[])};
71
+ const visited=new Set(listed);
72
+ if(typeof ts.getFileMatcherPatterns==='function'&&
73
+ result.every(file=>visited.has(path.dirname(path.resolve(file)))))try{
74
+ // Base paths and symlinked directories reach matchFiles through realpath
75
+ // deduplication without a listing; a retargeted link changes this answer.
76
+ for(const base of ts.getFileMatcherPatterns(full,args[1],args[2],
77
+ ts.sys.useCaseSensitiveFileNames,process.cwd()).basePaths)remember('realpath',base);
78
+ const extensions=Array.isArray(args[0])?args[0]:null;
79
+ for(const directory of visited){
80
+ // matchFiles also resolves every visited directory to detect cycles.
81
+ remember('realpath',directory);
82
+ remember('listing',directory,{extensions});
83
+ let entries=[];
84
+ try{entries=fs.readdirSync(directory,{withFileTypes:true});}
85
+ catch(error){if(!['ENOENT','ENOTDIR'].includes(error.code))throw error;}
86
+ for(const entry of entries)
87
+ if(entry.isSymbolicLink())remember('realpath',path.join(directory,entry.name));
88
+ }
89
+ options.covered=1;
90
+ }catch{/* uncovered listings are replayed only through the compiler API */}
91
+ queries.set('readDirectory\0'+full+JSON.stringify(args),
92
+ ['readDirectory',full,[...result].sort(),options]);
93
+ return result;
94
+ };
95
+ const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,'tsconfig.json'),{},
96
+ {...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,
97
+ onUnRecoverableConfigFileDiagnostic(){}});
98
+ if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
99
+ const host=ts.createCompilerHost(parsed.options);
100
+ Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,readDirectory,
101
+ realpath(file){remember('realpath',file);return ts.sys.realpath(file);},
102
+ getSourceFile(file,languageVersion,onError){
103
+ const text=readFile(file);return text===undefined?undefined:
104
+ ts.createSourceFile(file,text,languageVersion);
105
+ }});
106
+ const program=ts.createProgram({rootNames:parsed.fileNames,options:parsed.options,
107
+ projectReferences:parsed.projectReferences,host});
108
+ const files=program.getSourceFiles().map(file=>path.resolve(file.fileName));
109
+ // Stable answers are necessary even at first capture; a concurrent edit
110
+ // cannot become the basis for a reusable discovery result.
111
+ const facts=[...queries.values()];
112
+ // A certificate must cover every program file; references parse further
113
+ // configurations outside this host and are not certified.
114
+ const complete=!parsed.projectReferences?.length&&files.every(file=>queries.has('readFile\0'+file));
115
+ return {files,queries:complete&&queriesMatch(facts)?facts:null};
116
+ }
117
+ // Identity of a read file and, for a link, of its target. Writes through any
118
+ // alias change ctime. A file changed within the last 2 s has no identity, so a
119
+ // coarse timestamp cannot hide a second write in the same tick (git's racy rule).
120
+ export function fileIdentity(file,now=Date.now()) {
121
+ try{const link=fs.lstatSync(file,{bigint:true}),target=link.isSymbolicLink()?fs.statSync(file,{bigint:true}):link;
122
+ if(Number(link.ctimeNs/1000000n)>=now-2000||Number(target.ctimeNs/1000000n)>=now-2000)return null;
123
+ const fields=st=>['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'].map(key=>String(st[key]));
124
+ return JSON.stringify([fields(target),target===link?null:[fs.realpathSync(file),...fields(link)]]);}
125
+ catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
126
+ }
127
+ // In-process replay of a certificate. Covered directory listings are checked
128
+ // through their recorded listings instead of loading the compiler API. A read
129
+ // file is re-hashed until a match records its identity, taken before the read,
130
+ // and again whenever that identity changes.
131
+ export function queriesHold(queries,identities=new Map()) {
132
+ if(!queries?.length)return false;
133
+ return queries.every(([kind,file,answer,options])=>{
134
+ if(kind==='readDirectory')return options?.covered===1;
135
+ const identity=kind==='readFile'?fileIdentity(file):null;
136
+ if(identity&&identities.get(file)===identity)return true;
137
+ const held=JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer);
138
+ if(kind==='readFile'){if(held&&identity)identities.set(file,identity);else identities.delete(file);}
139
+ return held;
140
+ });
141
+ }
@@ -0,0 +1,77 @@
1
+ import {directProject,directQualification,directEnvironment} from './direct-typescript.mjs';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {realObservedPath} from './path-identity.mjs';
5
+
6
+ // This is a machine-enforced contract, not a claim inferred from a script name.
7
+ // Runtime validation checks effective compiler membership, configuration and
8
+ // toolchain identity. Supported discovery certificates still validate those
9
+ // filesystem queries; caller context is independently compared at the decision.
10
+ export function qualifyTypeScript(root,selected,discovered={}) {
11
+ if(selected.qualification===directQualification){
12
+ const issues=[];
13
+ try{
14
+ const compiler=directProject(root,selected.script);
15
+ if(selected.command?.length!==3||selected.command[0]!==process.execPath||
16
+ selected.command[1]!==compiler.entry||selected.command[2]!=='--noEmit'||
17
+ 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&&argv[3]===selected.script))issues.push('direct compiler probe missing');
28
+ return {qualified:issues.length===0,issues};
29
+ }
30
+ if(selected.qualification==='pnpm-tsc-v1'){
31
+ const issues=[];
32
+ const workspace=selected.workspace||'.';
33
+ const script=selected.script||selected.command?.at(-1);
34
+ try{
35
+ if(discovered.issue)throw Error(discovered.issue);
36
+ const contract=discovered.workspace;
37
+ if(!contract)throw Error('pnpm TypeScript input discovery unavailable');
38
+ issues.push(...contract.limitations);
39
+ const declared=contract.workspace.pkg.scripts[script].trim().split(/\s+/).slice(1);
40
+ if(path.resolve(root,selected.cwd||'.')!==path.join(root,contract.workspace.dir)||
41
+ selected.command?.[0]!==process.execPath||
42
+ selected.command?.[1]!==contract.tsc||
43
+ JSON.stringify(selected.command.slice(2))!==JSON.stringify(declared))
44
+ issues.push('standalone TypeScript invocation changed');
45
+ }catch(e){issues.push(e.message);}
46
+ if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='pnpm-typecheck-probe.mjs'))
47
+ issues.push('pnpm TypeScript input probe missing');
48
+ if(!selected.environment?.executableIdentity||
49
+ !['node'].every(name=>selected.environment?.pathExecutables?.includes(name))||
50
+ !['PNPM_','COREPACK_','npm_config_','DYLD_','TSGO_'].every(name=>
51
+ selected.environment?.prefixes?.includes(name)))
52
+ issues.push('pnpm execution context declaration incomplete');
53
+ return {qualified:issues.length===0,issues};
54
+ }
55
+ if(selected.qualification==='yarn-workspace-tsc-v1'){
56
+ const issues=[];
57
+ try{
58
+ if(!selected.workspace||selected.command?.length!==5||
59
+ selected.command[1]!=='workspace'||selected.command[2]!==selected.workspace||
60
+ selected.command[3]!=='run')issues.push('workspace command changed');
61
+ else if(discovered.issue)throw Error(discovered.issue);
62
+ else if(!discovered.workspace)throw Error('Yarn workspace input discovery unavailable');
63
+ }catch(e){issues.push(e.message);}
64
+ if(!selected.probes?.some(argv=>path.basename(argv[1]||'')==='yarn-workspace-typecheck-probe.mjs'))
65
+ issues.push('workspace TypeScript input probe missing');
66
+ if(!selected.environment?.executableIdentity||
67
+ !['node','yarn'].every(name=>selected.environment?.pathExecutables?.includes(name))||
68
+ !['YARN_','COREPACK_','npm_config_','DYLD_','TSGO_'].every(name=>
69
+ selected.environment?.prefixes?.includes(name)))
70
+ issues.push('Yarn execution context declaration incomplete');
71
+ return {qualified:issues.length===0,issues};
72
+ }
73
+ if(selected.qualification!=='typescript-noemit-v1')return null;
74
+ // Retired automatic launcher contract: npm's notifier consumes unobserved
75
+ // remote/cache/time state. A successful execution is still an outcome.
76
+ return {qualified:false,issues:['npm launcher applicability is unqualified: update notification uses unobserved remote/cache/time inputs; run the npm script when needed']};
77
+ }
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+
3
+ // Windows cannot CreateProcess a .cmd/.bat file directly. Keep the logical
4
+ // argv in the receipt, and launch only the well-defined, quoted batch subset
5
+ // through cmd.exe. Unrepresentable arguments fail instead of being altered.
6
+ export function windowsLaunch(command,{platform=process.platform,
7
+ comspec=process.env.ComSpec||process.env.COMSPEC||'C:\\Windows\\System32\\cmd.exe'}={}) {
8
+ if(platform!=='win32')return {file:command[0],args:command.slice(1),options:{}};
9
+ if(!/\.(?:cmd|bat)$/i.test(path.win32.extname(command[0])))
10
+ return {file:command[0],args:command.slice(1),options:{}};
11
+ if(command.some(value=>/[\r\n"%!^&|<>]/.test(value)))
12
+ throw Error('Windows batch command contains characters that cannot be passed safely; use an executable or Node script as exact argv');
13
+ const line='"'+command.map(value=>'"'+value+'"').join(' ')+'"';
14
+ return {file:comspec,args:['/d','/s','/c',line],
15
+ options:{windowsVerbatimArguments:true,windowsHide:false},mechanism:'cmd-batch'};
16
+ }