@redue/cli 0.1.0-alpha.10 → 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.
@@ -1,40 +1,185 @@
1
1
  // A caller validates discovery, never an execution outcome. No persistent cache.
2
+ // The direct compiler run records the answers it received (run-recorder.cjs);
3
+ // those answers, not a separate probe, show it consumed the certified inputs.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
2
6
  import {createHash} from 'node:crypto';
3
- import {spawnSync} from 'node:child_process';
4
7
  import {inflateSync} from 'node:zlib';
5
8
  import {fileURLToPath} from 'node:url';
6
9
  import {timing} from './decision-profile.mjs';
10
+ import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
11
+ import {withinPath} from './path-identity.mjs';
7
12
  const hash=value=>createHash('sha256').update(value).digest('hex');
8
13
  export function directRunCertificateAllowed(selected) {
9
- return process.platform==='darwin'&&selected.qualification==='npm-typescript-direct-v1'&&
14
+ return ['darwin','linux'].includes(process.platform)&&selected.qualification==='npm-typescript-direct-v1'&&
10
15
  selected.probes?.length===1&&
11
16
  selected.probes[0][1]===fileURLToPath(new URL('direct-typescript-probe.mjs',import.meta.url))&&
12
17
  !(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_RUN_PROBES==='1');
13
18
  }
14
- export function validateDirectRunCertificate(plan,selected,snapshot) {
19
+ // The start snapshot's discovery certificate, checked without a child process.
20
+ // Returns what a recorded run is compared against, or null for full probes.
21
+ export function directRunCertificate(plan,selected,snapshot,root) {
15
22
  const started=performance.now(),c=snapshot?.discoveryCertificate;
16
23
  const fallback=reason=>{timing('cli.run_certificate',started,{outcome:'fallback',reason});return null;};
17
24
  if(!directRunCertificateAllowed(selected))return fallback('unsupported');
18
- if(!snapshot?.observationHealthy||!c||c.schema!==1||c.planId!==plan.id||
25
+ if(!snapshot?.observationHealthy||!c||c.schema!==2||c.planId!==plan.id||
19
26
  c.planId!==snapshot.planId||c.cwd!==plan.cwd||
20
27
  c.probeDefinition!==hash(JSON.stringify(selected.probes))||
21
28
  ![c.output,c.context,c.stderrHash].every(s=>typeof s==='string'&&/^[a-f0-9]{64}$/.test(s))||
22
29
  typeof c.queryData!=='string'||c.queryData.length>512*1024)
23
30
  return fallback('certificate_unavailable');
31
+ // The compile runs in this process's environment; the daemon's is never substituted.
32
+ if(directEnvironmentIssue())return fallback('environment_unsupported');
24
33
  try{
25
- const queries=JSON.parse(inflateSync(Buffer.from(c.queryData,'base64'),{maxOutputLength:8*1024*1024}));
26
34
  const argv=selected.probes[0];
27
- const value=hash(JSON.stringify([[argv,0,hash(c.output),c.stderrHash]]));
35
+ const value=directProbeValue(argv,c.output,c.stderrHash);
28
36
  if(value!==snapshot.probeHash)return fallback('probe_identity_changed');
29
- // The built-in child checks the current implementation, actual caller
30
- // environment, context, content, membership and previously absent queries.
31
- // The daemon environment is never substituted for the caller's.
32
- const out=spawnSync(argv[0],[...argv.slice(1),'--redue-validate-queries'],{
33
- cwd:plan.cwd,env:process.env,timeout:10000,maxBuffer:1024*1024,
34
- input:JSON.stringify({schema:1,context:c.context,queries})});
35
- if(out.error||out.signal||out.status!==0||out.stdout.toString()!==c.context||
36
- hash(out.stderr)!==c.stderrHash)return fallback('caller_validation_failed');
37
- timing('cli.run_certificate',started,{outcome:'reused',queries:queries.length});
38
- return value;
37
+ // Bytes were verified by the daemon's full probe; its toolchain identity
38
+ // check at the start and end snapshots covers later changes.
39
+ const compiler=directCompiler(root,{verify:false});
40
+ if(selected.command[1]!==compiler.entry)return fallback('command_unsupported');
41
+ const queries=JSON.parse(inflateSync(Buffer.from(c.queryData,'base64'),{maxOutputLength:8*1024*1024}));
42
+ if(!Array.isArray(queries)||!queries.length)return fallback('certificate_invalid');
43
+ timing('cli.run_certificate',started,{outcome:'available',queries:queries.length});
44
+ return {value,queries,context:c.context,compiler};
39
45
  }catch{return fallback('certificate_invalid');}
40
46
  }
47
+ // Without a start certificate, the daemon may offer to build one from this run's
48
+ // recording at the end checkpoint. Returns its token, or null for full probes.
49
+ export function directRunRecording(plan,selected,snapshot,root) {
50
+ const token=snapshot?.recordedCertificate;
51
+ if(!directRunCertificateAllowed(selected)||!snapshot.observationHealthy||
52
+ typeof token!=='string'||!/^[0-9a-f-]{36}$/.test(token)||snapshot.planId!==plan.id||directEnvironmentIssue())
53
+ return null;
54
+ try{if(selected.command[1]!==directCompiler(root,{verify:false}).entry)return null;}catch{return null;}
55
+ return token;
56
+ }
57
+ // Answers derived from a recorded raw listing, exactly as queryAnswer computes them.
58
+ function derived(kind,entries,options) {
59
+ if(entries===null)return null;
60
+ if(kind==='entries')return entries.map(([name,type])=>[name,type==='d'?'d':type==='l'?'l':'f']).sort();
61
+ const resolved=entries.flatMap(([name,type,target])=>type!=='l'?[[name,type]]:target?[[name,target]]:[]);
62
+ if(kind==='directories'){
63
+ if(entries.some(([,type,target])=>type==='l'&&!target))return null;
64
+ return resolved.filter(([,type])=>type==='d').map(([name])=>name).sort();
65
+ }
66
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
67
+ return resolved.flatMap(([name,type])=>type==='d'?[[name,'d']]:
68
+ type==='f'&&(!suffixes||suffixes.some(value=>name.toLowerCase().endsWith(value)))?[[name,'f']]:[]).sort();
69
+ }
70
+ // What one certificate fact says about a recorded stat answer: true, false, or
71
+ // null when it says nothing. A parent listing that does not name the path only
72
+ // rules some answers out, so it must agree but does not count as cover.
73
+ function statAgrees([kind,file,answer,options],target,result) {
74
+ if(file===target){
75
+ // queryAnswer reports a missing path as null, not false.
76
+ if(answer===null)return ['fileExists','directoryExists','readFile'].includes(kind)?result===null:null;
77
+ if(kind==='fileExists')return result!==null&&(result==='f')===answer;
78
+ if(kind==='directoryExists')return result!==null&&(result==='d')===answer;
79
+ if(kind==='readFile')return result==='f';
80
+ return null;
81
+ }
82
+ if(file!==path.dirname(target)||!Array.isArray(answer))return null;
83
+ const name=path.basename(target);
84
+ if(kind==='directories')return answer.includes(name)?result==='d':result==='d'?false:undefined;
85
+ if(kind==='entries'){const entry=answer.find(row=>row[0]===name);
86
+ return !entry?result===null:entry[1]==='l'?null:entry[1]===result;}
87
+ if(kind==='listing'){const entry=answer.find(row=>row[0]===name);
88
+ if(entry)return entry[1]===result;
89
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
90
+ const listed=!suffixes||suffixes.some(value=>name.toLowerCase().endsWith(value));
91
+ return result==='d'||listed&&result==='f'?false:undefined;}
92
+ return null;
93
+ }
94
+ const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
95
+ file!==compiler.entry&&file.toLowerCase()===compiler.entry.toLowerCase();
96
+ // Null when every answer the run received agrees with the certified state,
97
+ // otherwise why not. The compiler is deterministic given its context and its
98
+ // answers, so a run whose answers all agree computed the certified result.
99
+ // Each answer needs at least one certificate fact about it. Paths inside the
100
+ // compiler tree are covered by the daemon's toolchain identity instead. tsc
101
+ // also stats a case-swapped copy of its own path to detect case sensitivity;
102
+ // only that one stat is exempt.
103
+ export function runRecordIssue(record,certificate) {
104
+ if(record?.schema!==1||!Array.isArray(record.facts))return 'record unavailable';
105
+ if(record.unsupported)return `unsupported ${record.unsupported}`;
106
+ if(record.conflict)return `answer changed during the run: ${record.conflict}`;
107
+ const {compiler,queries}=certificate;
108
+ if(record.main!==compiler.entry||!Array.isArray(record.node)||
109
+ directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
110
+ const inCompiler=withinCompiler(compiler);
111
+ const facts=new Map();
112
+ for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
113
+ for(const [kind,file,answer] of record.facts){
114
+ if(inCompiler(file))continue;
115
+ const known=facts.get(file)||[];
116
+ let checks=[];
117
+ if(kind==='read')checks=known.filter(q=>q[0]==='readFile')
118
+ .map(q=>JSON.stringify(q[2])===JSON.stringify(answer));
119
+ else if(kind==='realpath')checks=known.filter(q=>q[0]==='realpath')
120
+ .map(q=>q[2]===answer);
121
+ else if(kind==='readdir')checks=known.filter(q=>['listing','directories','entries'].includes(q[0]))
122
+ .map(q=>JSON.stringify(derived(q[0],answer,q[3]))===JSON.stringify(q[2]));
123
+ else if(kind==='stat')checks=[...known,...(facts.get(path.dirname(file))||[])]
124
+ .map(q=>statAgrees(q,file,answer)).filter(value=>value!==null);
125
+ else return `unknown answer ${kind}`;
126
+ if(!checks.some(value=>value!==undefined))return `${kind} ${file} is not in the discovery certificate`;
127
+ if(checks.includes(false))return `${kind} ${file} differs from the discovery certificate`;
128
+ }
129
+ return null;
130
+ }
131
+ // Compares the record run-recorder.cjs wrote to fd 3.
132
+ export function validateRunRecord(text,certificate) {
133
+ const started=performance.now();
134
+ let record=null;
135
+ try{record=JSON.parse(text);}catch{}
136
+ let issue;
137
+ try{issue=runRecordIssue(record,certificate);}catch{issue='record unreadable';}
138
+ timing('cli.run_recording',started,{outcome:issue?'rejected':'validated',
139
+ facts:record?.facts?.length||0,...(issue?{reason:issue}:{})});
140
+ return issue;
141
+ }
142
+ // A certificate for the current inputs, built from a recorded run and the
143
+ // certificate of the last full probe, or {issue}. The run must read the same
144
+ // files, with the same configuration, and every answer it received must be
145
+ // covered by the old questions; only file contents may differ. The program
146
+ // layout is then the same, so the output is rebuilt as the probe computes it.
147
+ // Limit: a file whose role changes (a package.json newly imported as a module)
148
+ // keeps its old role, so a later full probe can report the check STALE.
149
+ export function recordedCertificate(text,cache,compiler) {
150
+ let record=null;
151
+ try{record=JSON.parse(text);}catch{}
152
+ if(record?.schema!==1||!Array.isArray(record.facts))return {issue:'record unavailable'};
153
+ if(!cache?.layout||!cache.queries?.length)return {issue:'no previous layout'};
154
+ const inCompiler=withinCompiler(compiler),reads=new Map();
155
+ for(const [kind,file,answer] of record.facts)if(kind==='read'&&!inCompiler(file)){
156
+ if(answer!==null&&typeof answer!=='string')return {issue:`read ${file} failed`};
157
+ reads.set(file,answer);
158
+ }
159
+ const before=new Map(cache.queries.filter(q=>q[0]==='readFile'&&!inCompiler(q[1])).map(q=>[q[1],q[2]]));
160
+ if(reads.size!==before.size||[...before.keys()].some(file=>!reads.has(file)))
161
+ return {issue:'the run read different files'};
162
+ if(cache.layout.configs.some(([file])=>!reads.has(file)||reads.get(file)!==before.get(file)))
163
+ return {issue:'configuration changed'};
164
+ // Lib files are in the compiler tree: their bytes are covered by its identity,
165
+ // but which of them the program loads must not change.
166
+ const libs=list=>[...new Set(list.filter(file=>inCompiler(file)&&file.endsWith('.d.ts')))].sort();
167
+ if(!Array.isArray(record.compilerReads)||JSON.stringify(libs(record.compilerReads))!==
168
+ JSON.stringify(libs(cache.queries.filter(q=>q[0]==='readFile').map(q=>q[1]))))
169
+ return {issue:'the run loaded different lib files'};
170
+ const changed=new Set([...reads].filter(([file,answer])=>before.get(file)!==answer).map(([file])=>file));
171
+ const queries=cache.queries.map(q=>q[0]==='readFile'&&changed.has(q[1])?[q[0],q[1],reads.get(q[1]),...q.slice(3)]:q);
172
+ const issue=runRecordIssue(record,{compiler,queries,context:cache.context});
173
+ if(issue)return {issue};
174
+ const files=[];
175
+ for(const [file,digest] of cache.layout.files){
176
+ if(!changed.has(file)){files.push([file,digest]);continue;}
177
+ let bytes;
178
+ try{bytes=fs.readFileSync(file);}catch{return {issue:`${file} unreadable`};}
179
+ // The same answer the compiler received (queryAnswer's readFile).
180
+ if(hash(JSON.stringify(bytes.toString('utf8')))!==reads.get(file))return {issue:`${file} changed after the run`};
181
+ files.push([file,hash(bytes)]);
182
+ }
183
+ const layout={...cache.layout,files};
184
+ return {queries,layout,output:directProbeOutput(layout),changed};
185
+ }
@@ -1,13 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import {createHash} from 'node:crypto';
3
- import {directProject,directInterpretation} from './direct-typescript.mjs';
2
+ import {directProject,directInterpretation,directContextHash,directProbeOutput} from './direct-typescript.mjs';
4
3
  import {typeScriptInputFacts} from './typescript-input-facts.mjs';
5
4
  import {contextOnly,probeResult} from './probe-result.mjs';
6
5
  import {realObservedPath} from './path-identity.mjs';
7
- import path from 'node:path';
8
- import {queriesMatch} from './typescript-list.mjs';
9
- import {timing,mark} from './decision-profile.mjs';
10
- const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ import {timing} from './decision-profile.mjs';
11
7
  try{
12
8
  const [rootArg,script]=process.argv.slice(2),root=realObservedPath(rootArg);
13
9
  // Verify all installed implementation bytes before loading compiler API/code,
@@ -17,40 +13,10 @@ try{
17
13
  timing('direct_probe.implementation',implementationStarted,{version:compiler.version});
18
14
  const context={interpretation:directInterpretation,compiler:compiler.digest,
19
15
  entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
20
- const contextHash=hash(context);
21
- if(process.argv.includes('--redue-validate-queries')){
22
- // Internal, process-local discovery certificate; never a verification receipt.
23
- // Verify the current implementation above before queryAnswer can load its API.
24
- const started=performance.now();mark('certificate.input_start');
25
- // A synchronous fd-0 read can stall with spawnSync's piped input on macOS
26
- // Node 22.13.0. Drain the stream without blocking its input delivery. The
27
- // parent's existing deadline and the same size limit remain authoritative.
28
- const chunks=[];let bytes=0;
29
- for await(const chunk of process.stdin){
30
- bytes+=chunk.length;if(bytes>8*1024*1024)throw Error('certificate too large');
31
- chunks.push(chunk);
32
- }
33
- const raw=Buffer.concat(chunks).toString('utf8');mark('certificate.input_complete',{bytes});
34
- const certificate=JSON.parse(raw),kinds=new Set(['readDirectory','readFile',
35
- 'fileExists','directoryExists','realpath','directories','entries']);
36
- if(certificate.schema!==1||certificate.context!==contextHash||
37
- !Array.isArray(certificate.queries)||!certificate.queries.length||
38
- certificate.queries.some(q=>!Array.isArray(q)||q.length<3||q.length>4||
39
- !kinds.has(q[0])||typeof q[1]!=='string'||!path.isAbsolute(q[1])||
40
- q[0]==='readDirectory'&&(q[3]?.tsRoot!==compiler.tsRoot||!Array.isArray(q[3]?.args)||
41
- q[3]?.argsVersion!==1||!Array.isArray(q[3]?.undefinedArguments)||
42
- q[3].undefinedArguments.some(i=>!Number.isInteger(i)||i<0||i>=q[3].args.length))))
43
- throw Error('certificate unavailable');
44
- mark('certificate.queries_start',{queries:certificate.queries.length});
45
- const matched=queriesMatch(certificate.queries);
46
- mark('certificate.queries_complete',{matched});
47
- timing('direct_probe.query_validation',started,{queries:certificate.queries.length,matched:matched?1:0});
48
- if(!matched)throw Error('compiler queries changed');
49
- process.stdout.write(contextHash);
50
- process.exit(0);
51
- }
16
+ const contextHash=directContextHash(compiler,context.node);
52
17
  if(!contextOnly(contextHash)){
53
- const {queries,...inputs}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
54
- probeResult(hash({root,script,...inputs,...context}),contextHash,queries);
18
+ const {queries,configs,files}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
19
+ const layout={root,script,configs,files,context};
20
+ probeResult(directProbeOutput(layout),contextHash,queries,layout);
55
21
  }
56
22
  }catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:'typescript-contract-unavailable'}`);process.exitCode=2;}
@@ -15,23 +15,40 @@ const fail=code=>{throw Object.assign(Error(code),{code});};
15
15
  const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
16
16
  export const directEnvironment={variables:['NODE_ENV','NODE_OPTIONS','NODE_PATH','NODE_COMPILE_CACHE','VSCODE_INSPECTOR_OPTIONS'],
17
17
  prefixes:['NODE_','DYLD_','LD_','TSGO_','TSC_','TS_ETW_'],executableIdentity:true};
18
+ // NODE_OPTIONS flags that only size the V8 heap or pick TLS/DNS behavior; the
19
+ // reviewed compilers open no network connections. Anything else, including any
20
+ // code loader (--require, --import, --loader), is unclassified.
21
+ const inertNodeOptions=[/^--max-(?:old|semi)-space-size=\d+$/,/^--use-(?:openssl|bundled|system)-ca$/,
22
+ /^--dns-result-order=(?:ipv4first|ipv6first|verbatim)$/];
23
+ // One variable's effect on the direct compiler: null when it cannot change what
24
+ // tsc reads or reports. Accepted values stay fingerprinted as NODE_ context.
25
+ export function directVariableIssue(key,value){
26
+ const unsupported='direct-execution-environment-unsupported';
27
+ // The 5.5.2 API can require this unreviewed module during probe loading.
28
+ // Reject even empty/case aliases; do not mutate the caller's environment.
29
+ if(/^TS_ETW_MODULE_PATH$/i.test(key)||/^VSCODE_INSPECTOR_OPTIONS$/i.test(key)&&value)return unsupported;
30
+ if(/^NODE_/i.test(key)){
31
+ // Node 22.13.0 predates env-proxy initialization. This injected flag is
32
+ // inert there, but remains strictly fingerprinted as NODE_ context.
33
+ if(key==='NODE_USE_ENV_PROXY'&&value==='1'&&process.version==='v22.13.0'&&
34
+ !process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))return null;
35
+ if(key==='NODE_ENV'&&['','production','test'].includes(value))return null;
36
+ // PEM data for TLS roots, not code.
37
+ if(key==='NODE_EXTRA_CA_CERTS')return null;
38
+ // Only bare CommonJS requires consult it; the reviewed compilers make one,
39
+ // source-map-support, and only under NODE_ENV=development.
40
+ if(key==='NODE_PATH')return null;
41
+ // No quotes or escapes, so Node splits the value exactly as this does.
42
+ if(key==='NODE_OPTIONS'&&/^[A-Za-z0-9=._ -]*$/.test(value)&&
43
+ value.split(' ').filter(Boolean).every(flag=>inertNodeOptions.some(re=>re.test(flag))))return null;
44
+ if(value!=='')return unsupported;
45
+ }
46
+ if(/^(DYLD_|LD_|TSGO_|TSC_)/i.test(key)&&value!=='')return unsupported;
47
+ return null;
48
+ }
18
49
  export function directEnvironmentIssue(env=process.env){
19
50
  if(![22,24].includes(Number(process.versions.node.split('.')[0])))return 'direct-node-version-unsupported';
20
- if(env.VSCODE_INSPECTOR_OPTIONS)return 'direct-execution-environment-unsupported';
21
- for(const [key,value] of Object.entries(env)){
22
- // The 5.5.2 API can require this unreviewed module during probe loading.
23
- // Reject even empty/case aliases; do not mutate the caller's environment.
24
- if(/^TS_ETW_MODULE_PATH$/i.test(key))return 'direct-execution-environment-unsupported';
25
- if(/^NODE_/i.test(key)){
26
- // Node 22.13.0 predates env-proxy initialization. This injected flag is
27
- // inert there, but remains strictly fingerprinted as NODE_ context.
28
- if(key==='NODE_USE_ENV_PROXY'&&value==='1'&&process.version==='v22.13.0'&&
29
- !process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))continue;
30
- if(key==='NODE_ENV'&&['','production','test'].includes(value))continue;
31
- if(value!=='')return 'direct-execution-environment-unsupported';
32
- }
33
- if(/^(DYLD_|LD_|TSGO_|TSC_)/i.test(key)&&value!=='')return 'direct-execution-environment-unsupported';
34
- }
51
+ for(const [key,value] of Object.entries(env)){const issue=directVariableIssue(key,value);if(issue)return issue;}
35
52
  return null;
36
53
  }
37
54
  export function directCompiler(root,{verify=true}={}){
@@ -58,6 +75,22 @@ export function directCompiler(root,{verify=true}={}){
58
75
  }
59
76
  return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
60
77
  }
78
+ // The direct probe's output: root, script, configuration and program files with
79
+ // their byte digests, and the execution context. A recorded run rebuilds it from
80
+ // the same layout (direct-run-certificate.mjs).
81
+ export function directProbeOutput({root,script,configs,files,context}){
82
+ const named=list=>list.map(([file,digest])=>[hash(file),digest]);
83
+ return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context}));
84
+ }
85
+ // The probe value the daemon stores for one direct probe's checkpoint output.
86
+ export function directProbeValue(argv,output,stderrHash){
87
+ return hash(JSON.stringify([[argv,0,hash(output),stderrHash]]));
88
+ }
89
+ // The execution context a direct probe certifies and a recorded run must match.
90
+ export function directContextHash(compiler,node){
91
+ return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
92
+ entry:compiler.entry,node}));
93
+ }
61
94
  export function directProject(root,script,{verify=true}={}){
62
95
  const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
63
96
  if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
@@ -1,23 +1,26 @@
1
1
  // Native history is an optional acceleration. Keep its operations off the
2
2
  // control thread so a wedged native call cannot block status or shutdown.
3
+ // Each helper serves one query; the observer starts it ahead of that query.
3
4
  import fs from 'node:fs';
4
5
  import {mark} from './decision-profile.mjs';
5
6
  mark('history.worker_loaded');
6
7
  import watcher from '@parcel/watcher';
7
8
 
8
- try {
9
- const [root,previous,next,backend,faultFile]=process.argv.slice(2);
10
- let fault;
11
- try {fault=faultFile&&fs.readFileSync(faultFile,'utf8').trim();}
12
- catch(e){if(e.code!=='ENOENT')throw e;}
13
- if(fault==='hang')Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
14
- if(fault==='fail')throw Error('injected history failure');
15
- mark('history.snapshot_start');
16
- await watcher.writeSnapshot(root,next,{backend});
17
- mark('history.snapshot_complete');
18
- const changes=previous?await watcher.getEventsSince(root,previous,{backend}):[];
19
- mark('history.events_complete',{events:changes.length});
20
- process.send({ok:true,changes});
21
- } catch(error) {
22
- process.send({ok:false,error:error.message,code:error.code||error.name});
23
- }
9
+ process.on('disconnect',()=>process.exit(0));
10
+ process.once('message',async({root,previous,next,backend,faultFile})=>{
11
+ try {
12
+ let fault;
13
+ try {fault=faultFile&&fs.readFileSync(faultFile,'utf8').trim();}
14
+ catch(e){if(e.code!=='ENOENT')throw e;}
15
+ if(fault==='hang')Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
16
+ if(fault==='fail')throw Error('injected history failure');
17
+ mark('history.snapshot_start');
18
+ await watcher.writeSnapshot(root,next,{backend});
19
+ mark('history.snapshot_complete');
20
+ const changes=previous?await watcher.getEventsSince(root,previous,{backend}):[];
21
+ mark('history.events_complete',{events:changes.length});
22
+ process.send({ok:true,changes});
23
+ } catch(error) {
24
+ process.send({ok:false,error:error.message,code:error.code||error.name});
25
+ }
26
+ });
@@ -9,17 +9,39 @@ import {fileURLToPath} from 'node:url';
9
9
  // subscription is healthy; history may accelerate catch-up; a fresh scan is
10
10
  // the deterministic fallback. A failed history operation never updates cursor.
11
11
  const workerFile=path.join(path.dirname(fileURLToPath(import.meta.url)),'history-worker.mjs');
12
+ // Each query runs in a fresh helper: a reused native history session can take
13
+ // ten times longer. The next helper is forked once a query settles, so its
14
+ // startup is off the decision path. A failed or late helper is killed.
15
+ let spare=null,queue=Promise.resolve(),closed=false;
16
+ // After the observer's last query no helper is kept or started.
17
+ export function closeHistory() {closed=true;spare?.kill('SIGKILL');spare=null;}
18
+ function startHelper() {
19
+ const child=fork(workerFile,[],{stdio:['ignore','ignore','ignore','ipc'],env:traceEnvironment()});
20
+ child.abandoned=new Set();child.on('error',()=>{});
21
+ child.once('exit',()=>{if(spare===child)spare=null;
22
+ child.reply?.({ok:false,lost:Error(`history helper exited ${child.exitCode??child.signalCode}`)});
23
+ for(const next of child.abandoned)try{fs.unlinkSync(next);}catch{}});
24
+ return child;
25
+ }
12
26
  export function history(root,cursor,backend,deadlineMs,faultFile) {
27
+ const run=()=>query(root,cursor,backend,deadlineMs,faultFile);
28
+ const result=queue.then(run,run);queue=result.catch(()=>{});return result;
29
+ }
30
+ function query(root,cursor,backend,deadlineMs,faultFile) {
13
31
  const next=cursor+'.'+randomUUID()+'.next';
14
32
  return new Promise((resolve,reject)=>{
15
33
  mark('history.submit',{deadlineMs});
16
- const worker=fork(workerFile,[root,fs.existsSync(cursor)?cursor:'',next,backend,
17
- faultFile||''],{stdio:['ignore','ignore','ignore','ipc'],env:traceEnvironment()});
18
- let settled=false,abandoned=false;
34
+ const worker=spare??startHelper();spare=null;
35
+ worker.ref();worker.channel?.ref();
36
+ let settled=false;
19
37
  const finish=(error,value)=>{
20
- if(settled)return;settled=true;mark('history.complete',{childPid:worker.pid,outcome:error?.code||error?.name||'ok',events:value?.length});clearTimeout(timer);
38
+ if(settled)return;settled=true;worker.reply=null;clearTimeout(timer);
39
+ mark('history.complete',{childPid:worker.pid,outcome:error?.code||error?.name||'ok',events:value?.length});
40
+ if(error)worker.abandoned.add(next);
21
41
  worker.kill('SIGKILL');
22
- if(error){abandoned=true;try{fs.unlinkSync(next);}catch{}
42
+ // A failed query disables history for this observer; keep no helper.
43
+ if(!closed&&!error){spare=startHelper();spare.unref();spare.channel?.unref();}
44
+ if(error){try{fs.unlinkSync(next);}catch{}
23
45
  reject(error);return;}
24
46
  try{fs.renameSync(next,cursor);resolve(value);}
25
47
  catch(e){try{fs.unlinkSync(next);}catch{}reject(e);}
@@ -28,12 +50,10 @@ export function history(root,cursor,backend,deadlineMs,faultFile) {
28
50
  const e=Error(`history query did not settle within ${deadlineMs} ms`);
29
51
  e.code='ETIMEDOUT';finish(e);
30
52
  },deadlineMs);
31
- worker.on('message',message=>message.ok?finish(null,message.changes):
32
- finish(Object.assign(Error(message.error),{code:message.code})));
33
- worker.once('error',e=>finish(e));
34
- worker.once('exit',(code,signal)=>{
35
- if(abandoned)try{fs.unlinkSync(next);}catch{}
36
- if(!settled)finish(Error(`history helper exited ${code??signal}`));
37
- });
53
+ worker.reply=message=>message.lost?finish(message.lost):message.ok?finish(null,message.changes):
54
+ finish(Object.assign(Error(message.error),{code:message.code}));
55
+ worker.on('message',message=>worker.reply?.(message));
56
+ try{worker.send({root,previous:fs.existsSync(cursor)?cursor:'',next,backend,faultFile:faultFile||''});}
57
+ catch(e){finish(e);}
38
58
  });
39
59
  }
@@ -9,11 +9,15 @@ export function contextOnly(context) {
9
9
  }
10
10
  return false;
11
11
  }
12
- export function probeResult(output,context,queries=null) {
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) {
13
14
  if(!process.argv.includes('--redue-checkpoint')){process.stdout.write(output);return;}
14
15
  const raw=queries?JSON.stringify(queries):null;
15
- let value=JSON.stringify({output,context,queryData:raw&&Buffer.byteLength(raw)<=8*1024*1024?
16
- deflateSync(raw).toString('base64'):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});
17
21
  // Optimization metadata must not overflow the ordinary probe channel.
18
22
  // Larger contracts keep the normal complete probe, without result reuse.
19
23
  if(Buffer.byteLength(value)>512*1024)value=JSON.stringify({output,context,queries:null});
@@ -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
+ });
@@ -60,6 +60,9 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
60
60
  .map(file=>path.resolve(file));
61
61
  if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
62
62
  fail('typescript-input-outside-repository');
63
- return {configs:[...configReads].sort().map(file=>[hash(file),digestFile(file)]),
64
- files:[...new Set(files)].sort().map(file=>[hash(file),digestFile(file)]),queries:captured?.queries};
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};
65
68
  }