@redue/cli 0.1.0-alpha.11 → 0.1.0-alpha.13
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 +73 -0
- package/README.md +37 -4
- package/docs/user/agents.md +31 -14
- package/docs/user/configuration.md +17 -0
- package/docs/user/direct-typescript.md +44 -6
- package/docs/user/json.md +2 -0
- package/docs/user/pilot.md +4 -4
- package/docs/user/supported-projects.md +5 -5
- package/package.json +2 -2
- package/src/agent-integration.mjs +63 -19
- package/src/agent-policy.md +24 -66
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +59 -26
- package/src/cli.mjs +43 -19
- package/src/daemon.mjs +88 -40
- package/src/declared-plan.mjs +14 -2
- package/src/direct-run-certificate.mjs +84 -17
- package/src/direct-typescript-probe.mjs +2 -2
- package/src/direct-typescript.mjs +31 -2
- package/src/index.mjs +41 -11
- package/src/linux-inotify.py +10 -0
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +3 -15
- package/src/pnpm-inputs.mjs +3 -2
- package/src/probe-deadline.mjs +37 -0
- package/src/run-recorder.cjs +15 -2
- package/src/typescript-input-facts.mjs +32 -8
- package/src/typescript-list.mjs +28 -7
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
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/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/pnpm-inputs.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pinned pnpm 12 node-modules installation provider. The engine sees only
|
|
2
2
|
// repository-relative input sets and resolution relationships, never pnpm
|
|
3
3
|
// virtual-store names as semantic identities.
|
|
4
|
+
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
4
5
|
import {compilerFiles} from './typescript-list.mjs';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
@@ -96,7 +97,7 @@ export function pnpmWorkspacePackages(root,patterns){
|
|
|
96
97
|
const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
97
98
|
'-z','--',':(glob)**/package.json'],{cwd:root,
|
|
98
99
|
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:32*1024*1024,
|
|
99
|
-
timeout:5000}).toString().split('\0').filter(Boolean);
|
|
100
|
+
timeout:probeTimeLeft(5000)}).toString().split('\0').filter(Boolean);
|
|
100
101
|
for(const file of files){
|
|
101
102
|
const dir=path.posix.dirname(file);
|
|
102
103
|
if(dir==='.'||dir.split('/').includes('node_modules')||
|
|
@@ -202,7 +203,7 @@ function listedTypeScriptFiles(root,selected,tsc){
|
|
|
202
203
|
{cwd:path.join(root,selected.dir),env:process.env,encoding:'utf8',
|
|
203
204
|
// The native NTFS public workspace takes 3-5 s to list ~770 inputs;
|
|
204
205
|
// 5 s sometimes expired despite bounded, progressing compiler work.
|
|
205
|
-
timeout:10000,maxBuffer:32*1024*1024,stdio:['ignore','pipe','pipe']});
|
|
206
|
+
timeout:probeTimeLeft(10000),maxBuffer:32*1024*1024,stdio:['ignore','pipe','pipe']});
|
|
206
207
|
if(out.error||out.signal||out.status!==0)
|
|
207
208
|
throw Error('TypeScript input listing is unavailable');
|
|
208
209
|
const files=out.stdout.trim().split(/\r?\n/).filter(Boolean)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// How long a full probe may run before it is killed and the check stays
|
|
2
|
+
// UNVERIFIED. It grows with the project: the larger of the hint from earlier
|
|
3
|
+
// probes (twice the last successful one, or twice the deadline that last timed
|
|
4
|
+
// out) and the last completed run, between the platform floor and a ceiling.
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
export const probeCeilingMs=process.env.VSTATE_TEST_FAULTS==='1'&&
|
|
7
|
+
Number(process.env.VSTATE_TEST_PROBE_CEILING_MS)||60000;
|
|
8
|
+
// Supported TypeScript probes perform bounded input/toolchain hashing.
|
|
9
|
+
// Native NTFS runs have exceeded the generic 5 s cap under load, despite
|
|
10
|
+
// completing normally and with the duplicated work already removed.
|
|
11
|
+
export const probeFloorMs=qualification=>qualification==='pnpm-tsc-v1'||
|
|
12
|
+
(process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
|
|
13
|
+
.includes(qualification))?10000:5000;
|
|
14
|
+
// REDUE's own probes, which accept --redue-deadline; a user's probe never gets it.
|
|
15
|
+
const builtIn=new Set(['typescript-contract-probe.mjs','direct-typescript-probe.mjs',
|
|
16
|
+
'yarn-workspace-typecheck-probe.mjs','pnpm-typecheck-probe.mjs'].map(name=>fileURLToPath(new URL(name,import.meta.url))));
|
|
17
|
+
export const builtInProbe=argv=>builtIn.has(argv?.[1]);
|
|
18
|
+
// A deadline read from status.json, as a whole number of milliseconds no
|
|
19
|
+
// longer than a decision could need (0 when absent or unusable).
|
|
20
|
+
export function publishedDeadline(value) {
|
|
21
|
+
return Number.isFinite(value)&&value>0?Math.min(Math.round(value),10*60*1000):0;
|
|
22
|
+
}
|
|
23
|
+
export function probeDeadline({floor,hint=0,runMs=0,ceiling=probeCeilingMs}) {
|
|
24
|
+
const finite=value=>Number.isFinite(value)&&value>0?value:0;
|
|
25
|
+
return Math.round(Math.min(ceiling,Math.max(floor,finite(hint),finite(runMs))));
|
|
26
|
+
}
|
|
27
|
+
// The hint the next probe starts from.
|
|
28
|
+
export function nextProbeHint({durationMs,deadlineMs,timedOut}) {
|
|
29
|
+
return 2*(timedOut?deadlineMs:durationMs);
|
|
30
|
+
}
|
|
31
|
+
// Inside a probe process: what is left of the deadline its caller passed with
|
|
32
|
+
// --redue-deadline=MS, or the fallback when none was passed. An inner call
|
|
33
|
+
// always ends before its parent is killed, so it never outlives the probe.
|
|
34
|
+
export function probeTimeLeft(fallback) {
|
|
35
|
+
const deadline=Number(/^--redue-deadline=(\d+)$/.exec(process.argv.find(arg=>arg.startsWith('--redue-deadline='))||'')?.[1]);
|
|
36
|
+
return deadline?Math.max(1,Math.round(deadline-performance.now()-250)):fallback;
|
|
37
|
+
}
|
package/src/run-recorder.cjs
CHANGED
|
@@ -51,13 +51,26 @@ const realpath=observe('realpath',original.realpathSync,value=>value);
|
|
|
51
51
|
realpath.native=observe('realpath',original.native,value=>value);
|
|
52
52
|
fs.realpathSync=realpath;
|
|
53
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');
|
|
54
57
|
fs.openSync=function(file,flags,...rest){
|
|
55
|
-
if(!(flags===undefined||flags==='r'||flags==='rs'||flags===fs.constants.O_RDONLY))
|
|
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
|
+
}
|
|
56
62
|
return original.openSync.call(this,file,flags,...rest);
|
|
57
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
|
+
};
|
|
58
71
|
// Writes, and reads this record cannot answer for, make it unusable. readSync
|
|
59
72
|
// and fstatSync stay open: readFileSync reads through them.
|
|
60
|
-
for(const name of ['writeFileSync','appendFileSync','
|
|
73
|
+
for(const name of ['writeFileSync','appendFileSync','unlinkSync','utimesSync',
|
|
61
74
|
'renameSync','rmSync','watch','watchFile','existsSync','lstatSync','accessSync','opendirSync',
|
|
62
75
|
'readlinkSync','readFile','stat','lstat','readdir','realpath','access','exists','open','opendir',
|
|
63
76
|
'readlink','createReadStream']){
|
|
@@ -2,18 +2,21 @@
|
|
|
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';
|
|
6
|
+
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
5
7
|
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
6
8
|
import {timing} from './decision-profile.mjs';
|
|
7
9
|
const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
|
|
8
10
|
const digestFile=file=>hash(fs.readFileSync(file));
|
|
9
11
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
10
12
|
export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
11
|
-
const configReads=new Set(),visited=new Set();
|
|
13
|
+
const configReads=new Set(),visited=new Set(),referenced=[];
|
|
14
|
+
let buildInfo=null;
|
|
12
15
|
let ts;try{ts=require(tsRoot);}catch{}
|
|
13
16
|
if(typeof ts?.getParsedCommandLineOfConfigFile==='function'){
|
|
14
17
|
const host={...ts.sys,readFile(file){configReads.add(path.resolve(file));
|
|
15
18
|
return ts.sys.readFile(file);},onUnRecoverableConfigFileDiagnostic(){}};
|
|
16
|
-
const parseConfig=file=>{
|
|
19
|
+
const parseConfig=(file,top=false)=>{
|
|
17
20
|
const absolute=path.resolve(file);
|
|
18
21
|
if(!within(absolute,root))fail('typescript-config-outside-repository');
|
|
19
22
|
if(visited.has(absolute))return;visited.add(absolute);
|
|
@@ -21,13 +24,20 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
21
24
|
const parsed=ts.getParsedCommandLineOfConfigFile(absolute,{},host);
|
|
22
25
|
if(!parsed||parsed.errors?.length)fail('typescript-config-unavailable');
|
|
23
26
|
if(parsed.options?.plugins?.length)fail('typescript-plugin-unsupported');
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
// Plain tsc reads a referenced config but never builds it, so only the
|
|
28
|
+
// root config's build settings matter. An incremental root reads and
|
|
29
|
+
// writes one build-info file, which must be inside the checkout.
|
|
30
|
+
if(top){
|
|
31
|
+
if(parsed.options?.composite)fail('typescript-incremental-unsupported');
|
|
32
|
+
const found=buildInfoFor(ts,parsed.options,root);
|
|
33
|
+
if(found.issue)fail(found.issue);
|
|
34
|
+
buildInfo=found.file||null;
|
|
35
|
+
}else referenced.push(parsed);
|
|
26
36
|
for(const reference of parsed.projectReferences||[])
|
|
27
37
|
parseConfig(reference.path.endsWith('.json')?reference.path:
|
|
28
38
|
path.join(reference.path,'tsconfig.json'));
|
|
29
39
|
};
|
|
30
|
-
parseConfig(path.join(root,'tsconfig.json'));
|
|
40
|
+
parseConfig(path.join(root,'tsconfig.json'),true);
|
|
31
41
|
}else{
|
|
32
42
|
// TypeScript 7's native compiler does not expose the previous config API.
|
|
33
43
|
// Its CLI reports the effective config; restrict this path to one direct
|
|
@@ -36,7 +46,7 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
36
46
|
if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
|
|
37
47
|
fail('typescript-config-graph-unsupported');
|
|
38
48
|
const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
|
|
39
|
-
env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
|
|
49
|
+
env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
|
|
40
50
|
stdio:['ignore','pipe','pipe']});
|
|
41
51
|
if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
|
|
42
52
|
const effective=JSON.parse(shown.stdout);
|
|
@@ -50,7 +60,7 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
50
60
|
const listingStarted=performance.now();
|
|
51
61
|
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
|
|
52
62
|
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,
|
|
63
|
+
{cwd:root,env:process.env,encoding:'utf8',timeout:probeTimeLeft(process.platform==='win32'?10000:4000),
|
|
54
64
|
maxBuffer:16*1024*1024,
|
|
55
65
|
stdio:['ignore','pipe','pipe']});
|
|
56
66
|
timing('typescript_contract.list_files',listingStarted,{status:listed.status??null,
|
|
@@ -60,9 +70,23 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
|
|
|
60
70
|
.map(file=>path.resolve(file));
|
|
61
71
|
if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
|
|
62
72
|
fail('typescript-input-outside-repository');
|
|
73
|
+
// A program that uses a referenced project's sources or outputs depends on
|
|
74
|
+
// that project's build, which plain tsc does not run. tsc redirects only
|
|
75
|
+
// TypeScript sources; a shared .d.ts or .json is an ordinary input.
|
|
76
|
+
const declaration=/\.d\.[cm]?ts$|\.json$/;
|
|
77
|
+
const sources=new Set(),outputs=new Set(),outDirs=[];
|
|
78
|
+
for(const parsed of referenced){
|
|
79
|
+
const dirs=[parsed.options.outDir,parsed.options.declarationDir].filter(Boolean).map(dir=>path.resolve(dir));
|
|
80
|
+
outDirs.push(...dirs);
|
|
81
|
+
for(const file of parsed.fileNames.map(name=>path.resolve(name)))if(!declaration.test(file)){
|
|
82
|
+
sources.add(file);if(!dirs.length)outputs.add(file.replace(/\.[cm]?tsx?$/,ext=>ext.replace(/tsx?$/,'d.ts')));}
|
|
83
|
+
}
|
|
84
|
+
if(files.some(file=>sources.has(file)||outputs.has(file)||outDirs.some(dir=>within(file,dir))))
|
|
85
|
+
fail('typescript-project-reference-consumed');
|
|
63
86
|
// Every hashed configuration must also be a certified question.
|
|
64
87
|
const queries=captured?.queries&&[...configReads].every(file=>
|
|
65
88
|
captured.queries.some(([kind,read])=>kind==='readFile'&&read===file))?captured.queries:null;
|
|
66
89
|
return {configs:[...configReads].sort().map(file=>[file,digestFile(file)]),
|
|
67
|
-
files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries
|
|
90
|
+
files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries,
|
|
91
|
+
...(buildInfo?{buildInfo}:{})};
|
|
68
92
|
}
|
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) {
|
|
@@ -38,6 +39,24 @@ export function queryAnswer(kind,file,options) {
|
|
|
38
39
|
throw Error('unknown compiler filesystem question');
|
|
39
40
|
}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
40
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
|
+
}
|
|
41
60
|
export function queriesMatch(queries) {
|
|
42
61
|
if(!queries?.length)return false;
|
|
43
62
|
return queries.every(([kind,file,answer,options])=>
|
|
@@ -92,13 +111,15 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
92
111
|
['readDirectory',full,[...result].sort(),options]);
|
|
93
112
|
return result;
|
|
94
113
|
};
|
|
114
|
+
const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
|
|
115
|
+
// `extends` of a package config resolves through realpath too.
|
|
95
116
|
const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,'tsconfig.json'),{},
|
|
96
|
-
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,
|
|
117
|
+
{...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
|
|
97
118
|
onUnRecoverableConfigFileDiagnostic(){}});
|
|
98
119
|
if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
|
|
99
120
|
const host=ts.createCompilerHost(parsed.options);
|
|
100
121
|
Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,readDirectory,
|
|
101
|
-
realpath
|
|
122
|
+
realpath,
|
|
102
123
|
getSourceFile(file,languageVersion,onError){
|
|
103
124
|
const text=readFile(file);return text===undefined?undefined:
|
|
104
125
|
ts.createSourceFile(file,text,languageVersion);
|
|
@@ -109,9 +130,9 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
109
130
|
// Stable answers are necessary even at first capture; a concurrent edit
|
|
110
131
|
// cannot become the basis for a reusable discovery result.
|
|
111
132
|
const facts=[...queries.values()];
|
|
112
|
-
// A certificate must cover every program file
|
|
113
|
-
//
|
|
114
|
-
const complete
|
|
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));
|
|
115
136
|
return {files,queries:complete&&queriesMatch(facts)?facts:null};
|
|
116
137
|
}
|
|
117
138
|
// Identity of a read file and, for a link, of its target. Writes through any
|
|
@@ -119,8 +140,8 @@ export function compilerFiles(tsRoot,cwd) {
|
|
|
119
140
|
// coarse timestamp cannot hide a second write in the same tick (git's racy rule).
|
|
120
141
|
export function fileIdentity(file,now=Date.now()) {
|
|
121
142
|
try{const link=fs.lstatSync(file,{bigint:true}),target=link.isSymbolicLink()?fs.statSync(file,{bigint:true}):link;
|
|
122
|
-
if(
|
|
123
|
-
const fields=st=>
|
|
143
|
+
if(racyUntil(link)>=now||racyUntil(target)>=now)return null;
|
|
144
|
+
const fields=st=>identityFields.map(key=>String(st[key]));
|
|
124
145
|
return JSON.stringify([fields(target),target===link?null:[fs.realpathSync(file),...fields(link)]]);}
|
|
125
146
|
catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
126
147
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Read-only applicability probe. Its output is one digest or a fixed reason;
|
|
3
3
|
// source, configuration, environment values, and command output are not logged.
|
|
4
|
+
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
4
5
|
import {compilerFiles} from './typescript-list.mjs';
|
|
5
6
|
import {contextOnly,probeResult} from './probe-result.mjs';
|
|
6
7
|
import fs from 'node:fs';
|
|
@@ -79,7 +80,7 @@ try{
|
|
|
79
80
|
const tsc=yarnLocalTsc(root);
|
|
80
81
|
const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(path.join(root,'node_modules/typescript'),cwd):null;
|
|
81
82
|
const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'-p','.','--listFilesOnly'],
|
|
82
|
-
{cwd,env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
|
|
83
|
+
{cwd,env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
|
|
83
84
|
stdio:['ignore','pipe','pipe']});
|
|
84
85
|
if(listed.error||listed.signal||listed.status!==0)fail('typescript-input-list-unavailable');
|
|
85
86
|
const inputs=listed.stdout.trim().split(/\r?\n/).filter(Boolean)
|