@redue/cli 0.1.0-alpha.10 → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/README.md +1 -1
- package/bin/redue.mjs +35 -19
- package/docs/user/agents.md +70 -7
- package/docs/user/configuration.md +17 -0
- package/docs/user/direct-typescript.md +62 -6
- package/docs/user/json.md +1 -0
- package/docs/user/pilot.md +10 -7
- package/docs/user/supported-projects.md +5 -5
- package/package.json +2 -2
- package/src/agent-integration.mjs +115 -25
- package/src/agent-policy.md +24 -66
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +148 -0
- package/src/cli.mjs +67 -12
- package/src/daemon.mjs +291 -105
- package/src/decision-validation.mjs +10 -0
- package/src/declared-plan.mjs +14 -2
- package/src/direct-run-certificate.mjs +231 -19
- package/src/direct-typescript-probe.mjs +6 -40
- package/src/direct-typescript.mjs +77 -15
- package/src/history-worker.mjs +19 -16
- package/src/index.mjs +41 -11
- package/src/linux-inotify.py +10 -0
- package/src/observation.mjs +32 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +3 -15
- package/src/probe-result.mjs +7 -3
- package/src/run-recorder.cjs +91 -0
- package/src/typescript-input-facts.mjs +33 -7
- package/src/typescript-list.mjs +99 -9
package/src/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {createHash} from 'node:crypto';
|
|
|
5
5
|
import micromatch from './glob.mjs';
|
|
6
6
|
import {sha} from './plan.mjs';
|
|
7
7
|
import {resolveLinks} from './installed-inputs.mjs';
|
|
8
|
-
import {validInputRelative} from './path-identity.mjs';
|
|
8
|
+
import {validInputRelative,identityFields,racyUntil} from './path-identity.mjs';
|
|
9
9
|
|
|
10
10
|
const tracked = root => [...new Set(execFileSync('git',
|
|
11
11
|
['ls-files','--cached','--others','--exclude-standard','-z'],
|
|
@@ -35,37 +35,43 @@ const pathPrefixes = rel => {
|
|
|
35
35
|
return prefixes;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// An installed file is observed by identity, not content: every write changes
|
|
39
|
+
// ctime, so its entry changes without reading the whole installation. The
|
|
40
|
+
// compiler's own reads are content-checked by its probe and certificate. A file
|
|
41
|
+
// changed within the racy window could still be rewritten in the same timestamp
|
|
42
|
+
// tick at the same size; such files are tracked in `racy`, and the daemon waits
|
|
43
|
+
// the window out and records them again before it writes a run checkpoint.
|
|
44
|
+
const identity=st=>identityFields.map(key=>String(st[key])).join(':');
|
|
38
45
|
function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
|
|
39
46
|
{bigint:true})) {
|
|
40
47
|
const file=path.join(root, rel);
|
|
41
|
-
let value,references=[],bytes=Number(before.size);
|
|
48
|
+
let value,references=[],bytes=installed?0:Number(before.size),racy=0;
|
|
42
49
|
if(before.isSymbolicLink()){
|
|
43
50
|
const resolution=resolveLinks(file,root,[],true);
|
|
44
51
|
if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
|
|
45
52
|
const target=fs.statSync(resolution.physical,{bigint:true});
|
|
46
53
|
if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
hashRegular(resolution.physical).hash;
|
|
54
|
+
if(installed)racy=racyUntil(target);else bytes=Number(target.size);
|
|
55
|
+
value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
|
|
56
|
+
target.mode+':'+hashRegular(resolution.physical).hash);
|
|
50
57
|
references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
|
|
51
58
|
.split(path.sep).join('/')),path.relative(root,resolution.physical)
|
|
52
59
|
.split(path.sep).join('/')])];
|
|
53
|
-
bytes=Number(target.size);
|
|
54
60
|
const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
55
|
-
for(const key of
|
|
61
|
+
for(const key of identityFields)
|
|
56
62
|
if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
|
|
57
63
|
const resolutionAfter=resolveLinks(file,root,[],true);
|
|
58
64
|
if(resolutionAfter.physical!==resolution.physical||
|
|
59
65
|
JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
|
|
60
66
|
throw Error(`input link changed during inspection: ${rel}`);
|
|
61
67
|
}
|
|
62
|
-
else if(before.isFile())
|
|
63
|
-
|
|
68
|
+
else if(before.isFile()&&installed){value='stat:'+identity(before);racy=racyUntil(before);}
|
|
69
|
+
else if(before.isFile()) value='file:'+before.mode+':'+hashRegular(file).hash;
|
|
64
70
|
else throw Error(`non-file input: ${rel}`);
|
|
65
71
|
const after=fs.lstatSync(file,{bigint:true});
|
|
66
|
-
for(const key of
|
|
72
|
+
for(const key of identityFields)
|
|
67
73
|
if(before[key]!==after[key]) throw Error(`input changed during inspection: ${rel}`);
|
|
68
|
-
return {hash:sha(value),references,bytes};
|
|
74
|
+
return {hash:sha(value),references,bytes,racy};
|
|
69
75
|
}
|
|
70
76
|
const readBuffer=Buffer.allocUnsafe(65536);
|
|
71
77
|
function hashRegular(file) {
|
|
@@ -81,6 +87,7 @@ export class InputIndex {
|
|
|
81
87
|
const matcherStarted=performance.now();
|
|
82
88
|
this.bundle=bundle; this.root=bundle.root; this.files=new Map(); this.members=new Map();
|
|
83
89
|
this.symlinkRefs=new Map();this.aliasDependencies=new Map();
|
|
90
|
+
this.racy=new Map();
|
|
84
91
|
this.trackGit=options.trackGit!==false;
|
|
85
92
|
this.installedRoots=[...new Set(Object.values(bundle.plans).flatMap(plan=>
|
|
86
93
|
plan.installedPhysicalRoots||[]))];
|
|
@@ -149,6 +156,17 @@ export class InputIndex {
|
|
|
149
156
|
}
|
|
150
157
|
return {files,bytes,durationMs:performance.now()-started};
|
|
151
158
|
}
|
|
159
|
+
// When the racy window of the named checks' installed members ends (0: none).
|
|
160
|
+
racyUntil(names=null) {
|
|
161
|
+
let until=0;
|
|
162
|
+
for(const [rel,time] of this.racy)
|
|
163
|
+
if(!names||[...this.members.get(rel)||[]].some(name=>names.includes(name)))until=Math.max(until,time);
|
|
164
|
+
return until;
|
|
165
|
+
}
|
|
166
|
+
// Records again every installed file whose racy window has passed.
|
|
167
|
+
settleRacy() {
|
|
168
|
+
for(const [rel,time] of [...this.racy])if(time<=Date.now())this.record(rel,rel+' changed');
|
|
169
|
+
}
|
|
152
170
|
summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
|
|
153
171
|
revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
|
|
154
172
|
record(rel, changeReason=rel+' changed', observedStat=null) {
|
|
@@ -182,6 +200,7 @@ export class InputIndex {
|
|
|
182
200
|
}
|
|
183
201
|
}
|
|
184
202
|
const oldNames=this.members.get(rel)||new Set(), newNames=new Set(fresh===undefined?[]:matched);
|
|
203
|
+
if(inspection?.racy>Date.now()&&newNames.size)this.racy.set(rel,inspection.racy);else this.racy.delete(rel);
|
|
185
204
|
if(old===fresh && oldNames.size===newNames.size && [...oldNames].every(n=>newNames.has(n))) return false;
|
|
186
205
|
for(const name of new Set([...oldNames,...newNames])) {
|
|
187
206
|
const row=this.checks.get(name);
|
|
@@ -304,3 +323,14 @@ export class InputIndex {
|
|
|
304
323
|
return out;
|
|
305
324
|
}
|
|
306
325
|
}
|
|
326
|
+
|
|
327
|
+
// The index state a worker sends and a restart retains; hydrate() in daemon.mjs
|
|
328
|
+
// reads it back.
|
|
329
|
+
export function dehydrateIndex(value) {
|
|
330
|
+
return {files:[...value.files],
|
|
331
|
+
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
332
|
+
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
333
|
+
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
334
|
+
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
335
|
+
rehashedBytes:value.rehashedBytes,profile:value.profile,racy:[...value.racy]};
|
|
336
|
+
}
|
package/src/linux-inotify.py
CHANGED
|
@@ -73,10 +73,18 @@ def main(root):
|
|
|
73
73
|
file_paths = {}
|
|
74
74
|
intentional_removals = set()
|
|
75
75
|
|
|
76
|
+
# No input lies in a .git directory (config refuses such patterns and the
|
|
77
|
+
# index skips them), so none is watched: git creates object directories
|
|
78
|
+
# there on most commits, and each would be an observation gap.
|
|
79
|
+
def in_git(target):
|
|
80
|
+
return '.git' in os.path.relpath(target, root).split(os.sep)
|
|
81
|
+
|
|
76
82
|
def add_tree(folder):
|
|
77
83
|
stack = [folder]
|
|
78
84
|
while stack:
|
|
79
85
|
current = stack.pop()
|
|
86
|
+
if in_git(current):
|
|
87
|
+
continue
|
|
80
88
|
wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
|
|
81
89
|
if wd < 0:
|
|
82
90
|
raise OSError(ctypes.get_errno(), 'inotify_add_watch failed')
|
|
@@ -119,6 +127,8 @@ def main(root):
|
|
|
119
127
|
emit('gap', reason='inotify_watch_unknown')
|
|
120
128
|
return False
|
|
121
129
|
target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
|
|
130
|
+
if in_git(target):
|
|
131
|
+
continue
|
|
122
132
|
if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
|
|
123
133
|
emit('gap', reason='inotify_watch_lost')
|
|
124
134
|
return False
|
package/src/observation.mjs
CHANGED
|
@@ -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=
|
|
17
|
-
|
|
18
|
-
let settled=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;
|
|
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
|
-
|
|
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.
|
|
32
|
-
finish(Object.assign(Error(message.error),{code:message.code}))
|
|
33
|
-
worker.
|
|
34
|
-
worker.
|
|
35
|
-
|
|
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
|
}
|
package/src/path-identity.mjs
CHANGED
|
@@ -41,3 +41,11 @@ export function validInputRelative(rel,platform=process.platform) {
|
|
|
41
41
|
rel.includes('\\')))return false;
|
|
42
42
|
return true;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
// File identity fields. Any write changes ctime, but timestamps tick in
|
|
46
|
+
// milliseconds: a file changed within the racy window could be rewritten in the
|
|
47
|
+
// same tick at the same size without changing them (git's racy rule).
|
|
48
|
+
export const identityFields=['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'];
|
|
49
|
+
export const racyWindowMs=2000;
|
|
50
|
+
// ctime, which no ordinary tool can set ahead, ends the window.
|
|
51
|
+
export const racyUntil=st=>Number(st.ctimeNs/1000000n)+racyWindowMs;
|
package/src/plan-worker.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {createHash} from 'node:crypto';
|
|
5
5
|
import {discover} from './plan.mjs';
|
|
6
|
-
import {InputIndex} from './index.mjs';
|
|
6
|
+
import {InputIndex,dehydrateIndex} from './index.mjs';
|
|
7
7
|
import {directProject,directQualification} from './direct-typescript.mjs';
|
|
8
8
|
import {queriesMatch} from './typescript-list.mjs';
|
|
9
9
|
import {planGuard,inputKey} from './decision-validation.mjs';
|
|
@@ -113,26 +113,14 @@ try {
|
|
|
113
113
|
const outside=new InputIndex({root:boundary,plans},{trackGit:false,includeInstalled:true});
|
|
114
114
|
outside.coldScan();
|
|
115
115
|
if(outside.unavailable)throw Error(outside.unavailable);
|
|
116
|
-
external[boundary]=
|
|
117
|
-
aliasDependencies:[...outside.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
118
|
-
rows:[...outside.checks]
|
|
119
|
-
.map(([name,row])=>({name,files:[...row.files],sum:row.sum.toString('hex'),
|
|
120
|
-
revision:row.revision,lastReason:row.lastReason})),
|
|
121
|
-
eventCount:outside.eventCount,rehashedFiles:outside.rehashedFiles,
|
|
122
|
-
rehashedBytes:outside.rehashedBytes,profile:outside.profile};
|
|
116
|
+
external[boundary]=dehydrateIndex(outside);
|
|
123
117
|
}
|
|
124
118
|
timing('worker.total',totalStarted,{mode:'cold',files:index.files.size});
|
|
125
119
|
parentPort.postMessage({ok:true,bundle,discoveryMs,indexingMs,guard,planReused,
|
|
126
120
|
inputKeys:Object.fromEntries([...index.checks].map(([name,row])=>[name,inputKey(row)])),
|
|
127
121
|
restartMode:'cold-index',
|
|
128
122
|
external,
|
|
129
|
-
index:
|
|
130
|
-
aliasDependencies:[...index.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
131
|
-
rows:[...index.checks].map(([name,row])=>({
|
|
132
|
-
name,files:[...row.files],sum:row.sum.toString('hex'),revision:row.revision,
|
|
133
|
-
lastReason:row.lastReason})),
|
|
134
|
-
eventCount:index.eventCount,rehashedFiles:index.rehashedFiles,
|
|
135
|
-
rehashedBytes:index.rehashedBytes,profile:index.profile}});
|
|
123
|
+
index:dehydrateIndex(index)});
|
|
136
124
|
}
|
|
137
125
|
} catch(error) {
|
|
138
126
|
parentPort.postMessage({ok:false,error:error?.message||String(error),
|
package/src/probe-result.mjs
CHANGED
|
@@ -9,11 +9,15 @@ export function contextOnly(context) {
|
|
|
9
9
|
}
|
|
10
10
|
return false;
|
|
11
11
|
}
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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,91 @@
|
|
|
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
|
+
// The one write a direct run may make is its build-info file (incremental);
|
|
55
|
+
// it is recorded, and the caller checks it is the configured one.
|
|
56
|
+
const buildInfo=file=>typeof file==='string'&&file.endsWith('.tsbuildinfo');
|
|
57
|
+
fs.openSync=function(file,flags,...rest){
|
|
58
|
+
if(!(flags===undefined||flags==='r'||flags==='rs'||flags===fs.constants.O_RDONLY)){
|
|
59
|
+
if(flags==='w'&&buildInfo(file))note('write',path.resolve(file),null);
|
|
60
|
+
else unsupported??='openSync';
|
|
61
|
+
}
|
|
62
|
+
return original.openSync.call(this,file,flags,...rest);
|
|
63
|
+
};
|
|
64
|
+
// tsc creates the build-info directory one level at a time; each is recorded.
|
|
65
|
+
const mkdirSync=fs.mkdirSync;
|
|
66
|
+
fs.mkdirSync=function(file,options,...rest){
|
|
67
|
+
if(typeof file==='string'&&options===undefined)note('mkdir',path.resolve(file),null);
|
|
68
|
+
else unsupported??='mkdirSync';
|
|
69
|
+
return mkdirSync.call(this,file,options,...rest);
|
|
70
|
+
};
|
|
71
|
+
// Writes, and reads this record cannot answer for, make it unusable. readSync
|
|
72
|
+
// and fstatSync stay open: readFileSync reads through them.
|
|
73
|
+
for(const name of ['writeFileSync','appendFileSync','unlinkSync','utimesSync',
|
|
74
|
+
'renameSync','rmSync','watch','watchFile','existsSync','lstatSync','accessSync','opendirSync',
|
|
75
|
+
'readlinkSync','readFile','stat','lstat','readdir','realpath','access','exists','open','opendir',
|
|
76
|
+
'readlink','createReadStream']){
|
|
77
|
+
const call=fs[name];
|
|
78
|
+
fs[name]=Object.assign(function(...args){unsupported??=name;return call.apply(this,args);},call);
|
|
79
|
+
}
|
|
80
|
+
for(const name of ['readFile','stat','lstat','readdir','realpath','access','open','opendir','readlink']){
|
|
81
|
+
const call=fs.promises[name];
|
|
82
|
+
fs.promises[name]=function(...args){unsupported??=`promises.${name}`;return call.apply(this,args);};
|
|
83
|
+
}
|
|
84
|
+
process.on('exit',()=>{
|
|
85
|
+
try{
|
|
86
|
+
const buffer=Buffer.from(JSON.stringify({schema:1,node:[real(process.execPath),process.version],main,
|
|
87
|
+
unsupported,conflict,compilerReads:[...compilerReads],facts:[...facts].map(([key,value])=>{const at=key.indexOf('\0');
|
|
88
|
+
return [key.slice(0,at),key.slice(at+1),JSON.parse(value)];})}));
|
|
89
|
+
for(let at=0;at<buffer.length;)at+=original.writeSync(3,buffer,at,buffer.length-at);
|
|
90
|
+
}catch{}
|
|
91
|
+
});
|
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';
|
|
3
3
|
import {createRequire} from 'node:module';import {spawnSync} from 'node:child_process';
|
|
4
4
|
import {compilerFiles} from './typescript-list.mjs';
|
|
5
|
+
import {buildInfoFor} from './direct-typescript.mjs';
|
|
5
6
|
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
6
7
|
import {timing} from './decision-profile.mjs';
|
|
7
8
|
const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
|
|
8
9
|
const digestFile=file=>hash(fs.readFileSync(file));
|
|
9
10
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
10
11
|
export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
11
|
-
const configReads=new Set(),visited=new Set();
|
|
12
|
+
const configReads=new Set(),visited=new Set(),referenced=[];
|
|
13
|
+
let buildInfo=null;
|
|
12
14
|
let ts;try{ts=require(tsRoot);}catch{}
|
|
13
15
|
if(typeof ts?.getParsedCommandLineOfConfigFile==='function'){
|
|
14
16
|
const host={...ts.sys,readFile(file){configReads.add(path.resolve(file));
|
|
15
17
|
return ts.sys.readFile(file);},onUnRecoverableConfigFileDiagnostic(){}};
|
|
16
|
-
const parseConfig=file=>{
|
|
18
|
+
const parseConfig=(file,top=false)=>{
|
|
17
19
|
const absolute=path.resolve(file);
|
|
18
20
|
if(!within(absolute,root))fail('typescript-config-outside-repository');
|
|
19
21
|
if(visited.has(absolute))return;visited.add(absolute);
|
|
@@ -21,13 +23,20 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
21
23
|
const parsed=ts.getParsedCommandLineOfConfigFile(absolute,{},host);
|
|
22
24
|
if(!parsed||parsed.errors?.length)fail('typescript-config-unavailable');
|
|
23
25
|
if(parsed.options?.plugins?.length)fail('typescript-plugin-unsupported');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
// Plain tsc reads a referenced config but never builds it, so only the
|
|
27
|
+
// root config's build settings matter. An incremental root reads and
|
|
28
|
+
// writes one build-info file, which must be inside the checkout.
|
|
29
|
+
if(top){
|
|
30
|
+
if(parsed.options?.composite)fail('typescript-incremental-unsupported');
|
|
31
|
+
const found=buildInfoFor(ts,parsed.options,root);
|
|
32
|
+
if(found.issue)fail(found.issue);
|
|
33
|
+
buildInfo=found.file||null;
|
|
34
|
+
}else referenced.push(parsed);
|
|
26
35
|
for(const reference of parsed.projectReferences||[])
|
|
27
36
|
parseConfig(reference.path.endsWith('.json')?reference.path:
|
|
28
37
|
path.join(reference.path,'tsconfig.json'));
|
|
29
38
|
};
|
|
30
|
-
parseConfig(path.join(root,'tsconfig.json'));
|
|
39
|
+
parseConfig(path.join(root,'tsconfig.json'),true);
|
|
31
40
|
}else{
|
|
32
41
|
// TypeScript 7's native compiler does not expose the previous config API.
|
|
33
42
|
// Its CLI reports the effective config; restrict this path to one direct
|
|
@@ -60,6 +69,23 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
60
69
|
.map(file=>path.resolve(file));
|
|
61
70
|
if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
|
|
62
71
|
fail('typescript-input-outside-repository');
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
// A program that uses a referenced project's sources or outputs depends on
|
|
73
|
+
// that project's build, which plain tsc does not run. tsc redirects only
|
|
74
|
+
// TypeScript sources; a shared .d.ts or .json is an ordinary input.
|
|
75
|
+
const declaration=/\.d\.[cm]?ts$|\.json$/;
|
|
76
|
+
const sources=new Set(),outputs=new Set(),outDirs=[];
|
|
77
|
+
for(const parsed of referenced){
|
|
78
|
+
const dirs=[parsed.options.outDir,parsed.options.declarationDir].filter(Boolean).map(dir=>path.resolve(dir));
|
|
79
|
+
outDirs.push(...dirs);
|
|
80
|
+
for(const file of parsed.fileNames.map(name=>path.resolve(name)))if(!declaration.test(file)){
|
|
81
|
+
sources.add(file);if(!dirs.length)outputs.add(file.replace(/\.[cm]?tsx?$/,ext=>ext.replace(/tsx?$/,'d.ts')));}
|
|
82
|
+
}
|
|
83
|
+
if(files.some(file=>sources.has(file)||outputs.has(file)||outDirs.some(dir=>within(file,dir))))
|
|
84
|
+
fail('typescript-project-reference-consumed');
|
|
85
|
+
// Every hashed configuration must also be a certified question.
|
|
86
|
+
const queries=captured?.queries&&[...configReads].every(file=>
|
|
87
|
+
captured.queries.some(([kind,read])=>kind==='readFile'&&read===file))?captured.queries:null;
|
|
88
|
+
return {configs:[...configReads].sort().map(file=>[file,digestFile(file)]),
|
|
89
|
+
files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries,
|
|
90
|
+
...(buildInfo?{buildInfo}:{})};
|
|
65
91
|
}
|
package/src/typescript-list.mjs
CHANGED
|
@@ -5,6 +5,7 @@ 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) {
|
|
@@ -14,16 +15,48 @@ export function queryAnswer(kind,file,options) {
|
|
|
14
15
|
if(kind==='readFile')return hash(fs.readFileSync(file,'utf8'));
|
|
15
16
|
if(kind==='fileExists')return fs.statSync(file).isFile();
|
|
16
17
|
if(kind==='directoryExists')return fs.statSync(file).isDirectory();
|
|
17
|
-
|
|
18
|
+
// The compiler resolves through realpathSync.native outside Windows.
|
|
19
|
+
if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
|
|
18
20
|
if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
|
|
19
21
|
.filter(entry=>entry.isDirectory()||entry.isSymbolicLink()&&
|
|
20
22
|
fs.statSync(path.join(file,entry.name)).isDirectory()).map(entry=>entry.name).sort();
|
|
21
23
|
// Directory entries include names/types, including newly created candidates.
|
|
22
24
|
if(kind==='entries')return fs.readdirSync(file,{withFileTypes:true})
|
|
23
25
|
.map(entry=>[entry.name,entry.isDirectory()?'d':entry.isSymbolicLink()?'l':'f']).sort();
|
|
26
|
+
// A compiler directory listing: subdirectories and files with a requested
|
|
27
|
+
// extension, classifying links by their targets as matchFiles does.
|
|
28
|
+
if(kind==='listing'){
|
|
29
|
+
const suffixes=options?.extensions?.map(value=>value.toLowerCase());
|
|
30
|
+
return fs.readdirSync(file,{withFileTypes:true}).flatMap(entry=>{
|
|
31
|
+
let type=entry;
|
|
32
|
+
if(entry.isSymbolicLink())try{type=fs.statSync(path.join(file,entry.name));}catch{return [];}
|
|
33
|
+
if(type.isDirectory())return [[entry.name,'d']];
|
|
34
|
+
if(type.isFile()&&(!suffixes||suffixes.some(value=>entry.name.toLowerCase().endsWith(value))))
|
|
35
|
+
return [[entry.name,'f']];
|
|
36
|
+
return [];
|
|
37
|
+
}).sort();
|
|
38
|
+
}
|
|
24
39
|
throw Error('unknown compiler filesystem question');
|
|
25
40
|
}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
26
41
|
}
|
|
42
|
+
// Null when every run fact answers as it did during the run, otherwise the
|
|
43
|
+
// first path that does not. Asked in-process; nothing is launched.
|
|
44
|
+
export function runFactChange(facts) {
|
|
45
|
+
if(!Array.isArray(facts))return 'an unreadable run fact';
|
|
46
|
+
for(const fact of facts){
|
|
47
|
+
if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile'].includes(fact[0])||
|
|
48
|
+
typeof fact[1]!=='string')return 'an unreadable run fact';
|
|
49
|
+
const [kind,file,answer]=fact;
|
|
50
|
+
let now;try{now=queryAnswer(kind,file);}catch{return file;}
|
|
51
|
+
if(now!==answer)return file;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// Why a receipt's run facts no longer hold, or null when they all do.
|
|
56
|
+
export function runFactReason(facts) {
|
|
57
|
+
const changed=runFactChange(facts);
|
|
58
|
+
return changed&&`a lookup the last run made answers differently now: ${changed}`;
|
|
59
|
+
}
|
|
27
60
|
export function queriesMatch(queries) {
|
|
28
61
|
if(!queries?.length)return false;
|
|
29
62
|
return queries.every(([kind,file,answer,options])=>
|
|
@@ -33,31 +66,60 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
33
66
|
const require=createRequire(import.meta.url),ts=require(tsRoot);
|
|
34
67
|
if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
|
|
35
68
|
const queries=new Map();
|
|
36
|
-
const remember=(kind,file)=>{
|
|
37
|
-
const full=path.resolve(file),answer=queryAnswer(kind,full);
|
|
38
|
-
queries.set(kind+'\0'+full,
|
|
69
|
+
const remember=(kind,file,options)=>{
|
|
70
|
+
const full=path.resolve(file),answer=queryAnswer(kind,full,options);
|
|
71
|
+
queries.set(kind+'\0'+full+(options?JSON.stringify(options):''),
|
|
72
|
+
options?[kind,full,answer,options]:[kind,full,answer]);return answer;
|
|
39
73
|
};
|
|
40
74
|
const readFile=file=>{remember('readFile',file);return ts.sys.readFile(file);};
|
|
41
75
|
const fileExists=file=>{remember('fileExists',file);return ts.sys.fileExists(file);};
|
|
42
76
|
const directoryExists=file=>{remember('directoryExists',file);return ts.sys.directoryExists(file);};
|
|
43
77
|
const getDirectories=file=>{remember('directories',file);return ts.sys.getDirectories(file);};
|
|
44
78
|
const readDirectory=(dir,...args)=>{
|
|
45
|
-
|
|
79
|
+
// Reviewed compilers enumerate each visited directory through fs.readdirSync.
|
|
80
|
+
// Record those listings as ordinary questions so a validator without the
|
|
81
|
+
// compiler API can replay this answer; the listing itself stays authoritative.
|
|
82
|
+
const listed=[],readdirSync=fs.readdirSync;
|
|
83
|
+
fs.readdirSync=function(file,...rest){listed.push(path.resolve(String(file)));
|
|
84
|
+
return readdirSync.call(this,file,...rest);};
|
|
85
|
+
let result;try{result=ts.sys.readDirectory(dir,...args);}finally{fs.readdirSync=readdirSync;}
|
|
46
86
|
// JSON turns undefined array entries into null. TypeScript distinguishes
|
|
47
87
|
// them (an omitted exclude list is not a null list), so retain that fact.
|
|
48
88
|
const full=path.resolve(dir),options={tsRoot,args,argsVersion:1,
|
|
49
89
|
undefinedArguments:args.flatMap((value,i)=>value===undefined?[i]:[])};
|
|
90
|
+
const visited=new Set(listed);
|
|
91
|
+
if(typeof ts.getFileMatcherPatterns==='function'&&
|
|
92
|
+
result.every(file=>visited.has(path.dirname(path.resolve(file)))))try{
|
|
93
|
+
// Base paths and symlinked directories reach matchFiles through realpath
|
|
94
|
+
// deduplication without a listing; a retargeted link changes this answer.
|
|
95
|
+
for(const base of ts.getFileMatcherPatterns(full,args[1],args[2],
|
|
96
|
+
ts.sys.useCaseSensitiveFileNames,process.cwd()).basePaths)remember('realpath',base);
|
|
97
|
+
const extensions=Array.isArray(args[0])?args[0]:null;
|
|
98
|
+
for(const directory of visited){
|
|
99
|
+
// matchFiles also resolves every visited directory to detect cycles.
|
|
100
|
+
remember('realpath',directory);
|
|
101
|
+
remember('listing',directory,{extensions});
|
|
102
|
+
let entries=[];
|
|
103
|
+
try{entries=fs.readdirSync(directory,{withFileTypes:true});}
|
|
104
|
+
catch(error){if(!['ENOENT','ENOTDIR'].includes(error.code))throw error;}
|
|
105
|
+
for(const entry of entries)
|
|
106
|
+
if(entry.isSymbolicLink())remember('realpath',path.join(directory,entry.name));
|
|
107
|
+
}
|
|
108
|
+
options.covered=1;
|
|
109
|
+
}catch{/* uncovered listings are replayed only through the compiler API */}
|
|
50
110
|
queries.set('readDirectory\0'+full+JSON.stringify(args),
|
|
51
111
|
['readDirectory',full,[...result].sort(),options]);
|
|
52
112
|
return result;
|
|
53
113
|
};
|
|
114
|
+
const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
|
|
115
|
+
// `extends` of a package config resolves through realpath too.
|
|
54
116
|
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,'tsconfig.json'),{},
|
|
55
|
-
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,
|
|
117
|
+
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
|
|
56
118
|
onUnRecoverableConfigFileDiagnostic(){}});
|
|
57
119
|
if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
|
|
58
120
|
const host=ts.createCompilerHost(parsed.options);
|
|
59
|
-
Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,
|
|
60
|
-
realpath
|
|
121
|
+
Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,readDirectory,
|
|
122
|
+
realpath,
|
|
61
123
|
getSourceFile(file,languageVersion,onError){
|
|
62
124
|
const text=readFile(file);return text===undefined?undefined:
|
|
63
125
|
ts.createSourceFile(file,text,languageVersion);
|
|
@@ -68,5 +130,33 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
68
130
|
// Stable answers are necessary even at first capture; a concurrent edit
|
|
69
131
|
// cannot become the basis for a reusable discovery result.
|
|
70
132
|
const facts=[...queries.values()];
|
|
71
|
-
|
|
133
|
+
// A certificate must cover every program file. Referenced configurations
|
|
134
|
+
// are parsed through this host, so their reads and listings are certified.
|
|
135
|
+
const complete=files.every(file=>queries.has('readFile\0'+file));
|
|
136
|
+
return {files,queries:complete&&queriesMatch(facts)?facts:null};
|
|
137
|
+
}
|
|
138
|
+
// Identity of a read file and, for a link, of its target. Writes through any
|
|
139
|
+
// alias change ctime. A file changed within the last 2 s has no identity, so a
|
|
140
|
+
// coarse timestamp cannot hide a second write in the same tick (git's racy rule).
|
|
141
|
+
export function fileIdentity(file,now=Date.now()) {
|
|
142
|
+
try{const link=fs.lstatSync(file,{bigint:true}),target=link.isSymbolicLink()?fs.statSync(file,{bigint:true}):link;
|
|
143
|
+
if(racyUntil(link)>=now||racyUntil(target)>=now)return null;
|
|
144
|
+
const fields=st=>identityFields.map(key=>String(st[key]));
|
|
145
|
+
return JSON.stringify([fields(target),target===link?null:[fs.realpathSync(file),...fields(link)]]);}
|
|
146
|
+
catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
147
|
+
}
|
|
148
|
+
// In-process replay of a certificate. Covered directory listings are checked
|
|
149
|
+
// through their recorded listings instead of loading the compiler API. A read
|
|
150
|
+
// file is re-hashed until a match records its identity, taken before the read,
|
|
151
|
+
// and again whenever that identity changes.
|
|
152
|
+
export function queriesHold(queries,identities=new Map()) {
|
|
153
|
+
if(!queries?.length)return false;
|
|
154
|
+
return queries.every(([kind,file,answer,options])=>{
|
|
155
|
+
if(kind==='readDirectory')return options?.covered===1;
|
|
156
|
+
const identity=kind==='readFile'?fileIdentity(file):null;
|
|
157
|
+
if(identity&&identities.get(file)===identity)return true;
|
|
158
|
+
const held=JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer);
|
|
159
|
+
if(kind==='readFile'){if(held&&identity)identities.set(file,identity);else identities.delete(file);}
|
|
160
|
+
return held;
|
|
161
|
+
});
|
|
72
162
|
}
|