@redue/cli 0.1.0-alpha.11 → 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 +53 -0
- package/README.md +1 -1
- 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 +1 -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 +27 -12
- package/src/daemon.mjs +65 -33
- 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/run-recorder.cjs +15 -2
- package/src/typescript-input-facts.mjs +29 -6
- package/src/typescript-list.mjs +28 -7
|
@@ -10,12 +10,20 @@ import {timing} from './decision-profile.mjs';
|
|
|
10
10
|
import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
|
|
11
11
|
import {withinPath} from './path-identity.mjs';
|
|
12
12
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
13
|
-
|
|
13
|
+
// Whether a direct run is recorded at all: on macOS and Linux, always, so its
|
|
14
|
+
// run facts are known even when full probes check it.
|
|
15
|
+
export function directRunRecordable(selected) {
|
|
14
16
|
return ['darwin','linux'].includes(process.platform)&&selected.qualification==='npm-typescript-direct-v1'&&
|
|
15
17
|
selected.probes?.length===1&&
|
|
16
|
-
selected.probes[0][1]===fileURLToPath(new URL('direct-typescript-probe.mjs',import.meta.url))
|
|
18
|
+
selected.probes[0][1]===fileURLToPath(new URL('direct-typescript-probe.mjs',import.meta.url));
|
|
19
|
+
}
|
|
20
|
+
// Whether a recorded run may replace the probes (tests can force full probes).
|
|
21
|
+
export function directRunCertificateAllowed(selected) {
|
|
22
|
+
return directRunRecordable(selected)&&
|
|
17
23
|
!(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_RUN_PROBES==='1');
|
|
18
24
|
}
|
|
25
|
+
// The incremental build-info file the plan leaves out of the inputs, if any.
|
|
26
|
+
export const buildInfoPath=plan=>typeof plan?.buildInfo==='string'?path.join(plan.root,plan.buildInfo):null;
|
|
19
27
|
// The start snapshot's discovery certificate, checked without a child process.
|
|
20
28
|
// Returns what a recorded run is compared against, or null for full probes.
|
|
21
29
|
export function directRunCertificate(plan,selected,snapshot,root) {
|
|
@@ -26,7 +34,7 @@ export function directRunCertificate(plan,selected,snapshot,root) {
|
|
|
26
34
|
c.planId!==snapshot.planId||c.cwd!==plan.cwd||
|
|
27
35
|
c.probeDefinition!==hash(JSON.stringify(selected.probes))||
|
|
28
36
|
![c.output,c.context,c.stderrHash].every(s=>typeof s==='string'&&/^[a-f0-9]{64}$/.test(s))||
|
|
29
|
-
typeof c.queryData!=='string'||c.queryData.length>
|
|
37
|
+
typeof c.queryData!=='string'||c.queryData.length>1024*1024)
|
|
30
38
|
return fallback('certificate_unavailable');
|
|
31
39
|
// The compile runs in this process's environment; the daemon's is never substituted.
|
|
32
40
|
if(directEnvironmentIssue())return fallback('environment_unsupported');
|
|
@@ -41,9 +49,21 @@ export function directRunCertificate(plan,selected,snapshot,root) {
|
|
|
41
49
|
const queries=JSON.parse(inflateSync(Buffer.from(c.queryData,'base64'),{maxOutputLength:8*1024*1024}));
|
|
42
50
|
if(!Array.isArray(queries)||!queries.length)return fallback('certificate_invalid');
|
|
43
51
|
timing('cli.run_certificate',started,{outcome:'available',queries:queries.length});
|
|
44
|
-
return {value,queries,context:c.context,compiler};
|
|
52
|
+
return {value,queries,context:c.context,compiler,buildInfo:buildInfoPath(plan)};
|
|
45
53
|
}catch{return fallback('certificate_invalid');}
|
|
46
54
|
}
|
|
55
|
+
// The last probe's questions, sent for a run checked by full probes. They only
|
|
56
|
+
// show which of the run's lookups are run facts; the full probes, not these,
|
|
57
|
+
// establish the result.
|
|
58
|
+
export function probeQuestions(plan,selected,snapshot,root) {
|
|
59
|
+
const q=snapshot?.probeQuestions;
|
|
60
|
+
if(!directRunRecordable(selected)||typeof q?.queryData!=='string'||
|
|
61
|
+
q.queryData.length>1024*1024||typeof q.context!=='string')return null;
|
|
62
|
+
try{const queries=JSON.parse(inflateSync(Buffer.from(q.queryData,'base64'),{maxOutputLength:8*1024*1024}));
|
|
63
|
+
if(!Array.isArray(queries)||!queries.length)return null;
|
|
64
|
+
return {queries,context:q.context,compiler:directCompiler(root,{verify:false}),buildInfo:buildInfoPath(plan)};}
|
|
65
|
+
catch{return null;}
|
|
66
|
+
}
|
|
47
67
|
// Without a start certificate, the daemon may offer to build one from this run's
|
|
48
68
|
// recording at the end checkpoint. Returns its token, or null for full probes.
|
|
49
69
|
export function directRunRecording(plan,selected,snapshot,root) {
|
|
@@ -93,6 +113,18 @@ function statAgrees([kind,file,answer,options],target,result) {
|
|
|
93
113
|
}
|
|
94
114
|
const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
|
|
95
115
|
file!==compiler.entry&&file.toLowerCase()===compiler.entry.toLowerCase();
|
|
116
|
+
// The certificate question that asks again what a run's stat, realpath or
|
|
117
|
+
// package.json read answered, in queryAnswer's terms; null when it cannot be asked again.
|
|
118
|
+
function runFact(kind,file,answer) {
|
|
119
|
+
if(kind==='stat')return answer===null||answer==='f'?['fileExists',file,answer&&true]:
|
|
120
|
+
answer==='d'?['directoryExists',file,true]:null;
|
|
121
|
+
if(kind==='realpath')return answer===null||typeof answer==='string'?['realpath',file,answer]:null;
|
|
122
|
+
// Declaration checks read the package.json of each dependency they resolve.
|
|
123
|
+
// Its content hash is asked again like any other run fact; other reads are not.
|
|
124
|
+
if(kind==='read'&&path.basename(file)==='package.json')
|
|
125
|
+
return answer===null||typeof answer==='string'?['readFile',file,answer]:null;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
96
128
|
// Null when every answer the run received agrees with the certified state,
|
|
97
129
|
// otherwise why not. The compiler is deterministic given its context and its
|
|
98
130
|
// answers, so a run whose answers all agree computed the certified result.
|
|
@@ -100,17 +132,36 @@ const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
|
|
|
100
132
|
// compiler tree are covered by the daemon's toolchain identity instead. tsc
|
|
101
133
|
// also stats a case-swapped copy of its own path to detect case sensitivity;
|
|
102
134
|
// only that one stat is exempt.
|
|
103
|
-
|
|
135
|
+
// With a `facts` array, a stat or realpath the probe never asked about is
|
|
136
|
+
// added to it instead (declaration diagnostics resolve module paths that
|
|
137
|
+
// building the program does not), as is a read of a package.json. The receipt
|
|
138
|
+
// keeps those run facts, and status asks them again before every CURRENT
|
|
139
|
+
// answer. Any other uncovered read, and any directory listing, is an issue.
|
|
140
|
+
// The record's facts without the build-info file an incremental run reads and
|
|
141
|
+
// writes, or {issue} when the run wrote anything else. That file is compiler
|
|
142
|
+
// state, not an input. A directory tsc created for it was missing when tsc
|
|
143
|
+
// checked it, so that check goes too; every other check stays.
|
|
144
|
+
export function withoutBuildInfo(facts,buildInfo) {
|
|
145
|
+
const created=new Set(facts.filter(([kind])=>kind==='mkdir').map(([,file])=>file));
|
|
146
|
+
for(const [kind,file] of facts){
|
|
147
|
+
if(kind==='write'&&file!==buildInfo)return {issue:`the compiler wrote ${file}`};
|
|
148
|
+
if(kind==='mkdir'&&!buildInfo?.startsWith(file+path.sep))return {issue:`the compiler created ${file}`};
|
|
149
|
+
}
|
|
150
|
+
return {facts:facts.filter(([kind,file])=>kind!=='write'&&kind!=='mkdir'&&file!==buildInfo&&
|
|
151
|
+
!(kind==='stat'&&created.has(file)))};
|
|
152
|
+
}
|
|
153
|
+
export function runRecordIssue(record,certificate,runFacts=null) {
|
|
104
154
|
if(record?.schema!==1||!Array.isArray(record.facts))return 'record unavailable';
|
|
105
155
|
if(record.unsupported)return `unsupported ${record.unsupported}`;
|
|
106
156
|
if(record.conflict)return `answer changed during the run: ${record.conflict}`;
|
|
107
|
-
const {compiler,queries}=certificate;
|
|
157
|
+
const {compiler,queries}=certificate,kept=withoutBuildInfo(record.facts,certificate.buildInfo||null);
|
|
158
|
+
if(kept.issue)return kept.issue;
|
|
108
159
|
if(record.main!==compiler.entry||!Array.isArray(record.node)||
|
|
109
160
|
directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
|
|
110
161
|
const inCompiler=withinCompiler(compiler);
|
|
111
162
|
const facts=new Map();
|
|
112
163
|
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
|
|
164
|
+
for(const [kind,file,answer] of kept.facts){
|
|
114
165
|
if(inCompiler(file))continue;
|
|
115
166
|
const known=facts.get(file)||[];
|
|
116
167
|
let checks=[];
|
|
@@ -123,40 +174,54 @@ export function runRecordIssue(record,certificate) {
|
|
|
123
174
|
else if(kind==='stat')checks=[...known,...(facts.get(path.dirname(file))||[])]
|
|
124
175
|
.map(q=>statAgrees(q,file,answer)).filter(value=>value!==null);
|
|
125
176
|
else return `unknown answer ${kind}`;
|
|
126
|
-
if(!checks.some(value=>value!==undefined))
|
|
177
|
+
if(!checks.some(value=>value!==undefined)){
|
|
178
|
+
const fact=runFacts&&runFact(kind,file,answer);
|
|
179
|
+
if(fact){runFacts.push(fact);continue;}
|
|
180
|
+
return `${kind} ${file} is not in the discovery certificate`;
|
|
181
|
+
}
|
|
127
182
|
if(checks.includes(false))return `${kind} ${file} differs from the discovery certificate`;
|
|
183
|
+
// Covered only by a listing of its directory: a later probe may keep the
|
|
184
|
+
// same program while that listing changes, so the run's own lookup stays
|
|
185
|
+
// a run fact.
|
|
186
|
+
if(kind==='stat'&&runFacts&&!known.length){const fact=runFact(kind,file,answer);if(fact)runFacts.push(fact);}
|
|
128
187
|
}
|
|
129
188
|
return null;
|
|
130
189
|
}
|
|
131
|
-
// Compares the record run-recorder.cjs wrote to fd 3.
|
|
132
|
-
export function validateRunRecord(text,certificate) {
|
|
190
|
+
// Compares the record run-recorder.cjs wrote to fd 3. Run facts go to `facts`.
|
|
191
|
+
export function validateRunRecord(text,certificate,facts=[]) {
|
|
133
192
|
const started=performance.now();
|
|
134
193
|
let record=null;
|
|
135
194
|
try{record=JSON.parse(text);}catch{}
|
|
136
195
|
let issue;
|
|
137
|
-
try{issue=runRecordIssue(record,certificate);}catch{issue='record unreadable';}
|
|
196
|
+
try{issue=runRecordIssue(record,certificate,facts);}catch{issue='record unreadable';}
|
|
138
197
|
timing('cli.run_recording',started,{outcome:issue?'rejected':'validated',
|
|
139
|
-
facts:record?.facts?.length||0,...(issue?{reason:issue}:{})});
|
|
198
|
+
facts:record?.facts?.length||0,runFacts:facts.length,...(issue?{reason:issue}:{})});
|
|
140
199
|
return issue;
|
|
141
200
|
}
|
|
142
201
|
// A certificate for the current inputs, built from a recorded run and the
|
|
143
202
|
// certificate of the last full probe, or {issue}. The run must read the same
|
|
144
203
|
// files, with the same configuration, and every answer it received must be
|
|
145
|
-
// covered by the old questions; only file contents may differ.
|
|
204
|
+
// covered by the old questions or be a run fact; only file contents may differ.
|
|
205
|
+
// Run facts stay out of this certificate: the receipt keeps them, and status
|
|
206
|
+
// asks them again for every answer. The program
|
|
146
207
|
// layout is then the same, so the output is rebuilt as the probe computes it.
|
|
147
208
|
// Limit: a file whose role changes (a package.json newly imported as a module)
|
|
148
209
|
// keeps its old role, so a later full probe can report the check STALE.
|
|
149
|
-
export function recordedCertificate(text,cache,compiler) {
|
|
210
|
+
export function recordedCertificate(text,cache,compiler,buildInfo=null) {
|
|
150
211
|
let record=null;
|
|
151
212
|
try{record=JSON.parse(text);}catch{}
|
|
152
213
|
if(record?.schema!==1||!Array.isArray(record.facts))return {issue:'record unavailable'};
|
|
153
214
|
if(!cache?.layout||!cache.queries?.length)return {issue:'no previous layout'};
|
|
215
|
+
const kept=withoutBuildInfo(record.facts,buildInfo);
|
|
216
|
+
if(kept.issue)return {issue:kept.issue};
|
|
154
217
|
const inCompiler=withinCompiler(compiler),reads=new Map();
|
|
155
|
-
|
|
218
|
+
const before=new Map(cache.queries.filter(q=>q[0]==='readFile'&&!inCompiler(q[1])).map(q=>[q[1],q[2]]));
|
|
219
|
+
for(const [kind,file,answer] of kept.facts)if(kind==='read'&&!inCompiler(file)){
|
|
156
220
|
if(answer!==null&&typeof answer!=='string')return {issue:`read ${file} failed`};
|
|
221
|
+
// A package.json only declaration checks read is a run fact, not a program read.
|
|
222
|
+
if(!before.has(file)&&path.basename(file)==='package.json')continue;
|
|
157
223
|
reads.set(file,answer);
|
|
158
224
|
}
|
|
159
|
-
const before=new Map(cache.queries.filter(q=>q[0]==='readFile'&&!inCompiler(q[1])).map(q=>[q[1],q[2]]));
|
|
160
225
|
if(reads.size!==before.size||[...before.keys()].some(file=>!reads.has(file)))
|
|
161
226
|
return {issue:'the run read different files'};
|
|
162
227
|
if(cache.layout.configs.some(([file])=>!reads.has(file)||reads.get(file)!==before.get(file)))
|
|
@@ -169,7 +234,9 @@ export function recordedCertificate(text,cache,compiler) {
|
|
|
169
234
|
return {issue:'the run loaded different lib files'};
|
|
170
235
|
const changed=new Set([...reads].filter(([file,answer])=>before.get(file)!==answer).map(([file])=>file));
|
|
171
236
|
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
|
-
|
|
237
|
+
// Run facts belong to the receipt, which the caller builds; they do not
|
|
238
|
+
// change the program layout.
|
|
239
|
+
const issue=runRecordIssue({...record,facts:kept.facts},{compiler,queries,context:cache.context},[]);
|
|
173
240
|
if(issue)return {issue};
|
|
174
241
|
const files=[];
|
|
175
242
|
for(const [file,digest] of cache.layout.files){
|
|
@@ -15,8 +15,8 @@ try{
|
|
|
15
15
|
entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
|
|
16
16
|
const contextHash=directContextHash(compiler,context.node);
|
|
17
17
|
if(!contextOnly(contextHash)){
|
|
18
|
-
const {queries,configs,files}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
|
|
19
|
-
const layout={root,script,configs,files,context};
|
|
18
|
+
const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
|
|
19
|
+
const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
|
|
20
20
|
probeResult(directProbeOutput(layout),contextHash,queries,layout);
|
|
21
21
|
}
|
|
22
22
|
}catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:'typescript-contract-unavailable'}`);process.exitCode=2;}
|
|
@@ -10,7 +10,10 @@ const reviewed={
|
|
|
10
10
|
'5.5.2':{entry:'lib/tsc.js',digest:'953b4844b5f6edc745be844b6c367c227b59029b73c4182f508f90cc47e21816'},
|
|
11
11
|
'5.6.3':{entry:'lib/tsc.js',digest:'e674b5dc4dac50f6e91be30094aeea983a1f340231717e62b0b6199ae3049eb2'},
|
|
12
12
|
'5.9.3':{entry:'lib/_tsc.js',digest:'d1b32e75475710fdc85102b1365254d84ceb192d3639d962c53fe1355ce274ca'},
|
|
13
|
+
'6.0.2':{entry:'lib/_tsc.js',digest:'273e16620fe23362e085980d6dd822e1315d81ba792221c9b6008755fdafccdd'},
|
|
14
|
+
'6.0.3':{entry:'lib/_tsc.js',digest:'e0a6519f0d0a90125b7fd3bdbbcf700e167656beb3baf8aca8ed8bc037494537'},
|
|
13
15
|
};
|
|
16
|
+
export const reviewedTypeScriptVersions=Object.keys(reviewed);
|
|
14
17
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
15
18
|
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
16
19
|
export const directEnvironment={variables:['NODE_ENV','NODE_OPTIONS','NODE_PATH','NODE_COMPILE_CACHE','VSCODE_INSPECTOR_OPTIONS'],
|
|
@@ -78,9 +81,35 @@ export function directCompiler(root,{verify=true}={}){
|
|
|
78
81
|
// The direct probe's output: root, script, configuration and program files with
|
|
79
82
|
// their byte digests, and the execution context. A recorded run rebuilds it from
|
|
80
83
|
// the same layout (direct-run-certificate.mjs).
|
|
81
|
-
export function directProbeOutput({root,script,configs,files,context}){
|
|
84
|
+
export function directProbeOutput({root,script,configs,files,context,buildInfo}){
|
|
82
85
|
const named=list=>list.map(([file,digest])=>[hash(file),digest]);
|
|
83
|
-
return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context
|
|
86
|
+
return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context,
|
|
87
|
+
...(buildInfo?{buildInfo}:{})}));
|
|
88
|
+
}
|
|
89
|
+
// The build-info file an incremental root config makes tsc read and write,
|
|
90
|
+
// as {file} relative to the root, {} when not incremental, or {issue}. The
|
|
91
|
+
// probe and the plan both use this rule. It must be inside the checkout, end
|
|
92
|
+
// in .tsbuildinfo (the only write the run recorder allows) and contain no glob
|
|
93
|
+
// characters (the plan excludes it with a pattern). An option that makes the
|
|
94
|
+
// result depend on the build-info history is refused.
|
|
95
|
+
export function buildInfoFor(ts,options,root) {
|
|
96
|
+
if(!options?.incremental)return {};
|
|
97
|
+
if(options.assumeChangesOnlyAffectDirectDependencies)return {issue:'typescript-incremental-unsound'};
|
|
98
|
+
const file=ts.getTsBuildInfoEmitOutputFilePath?.(options);
|
|
99
|
+
if(!file||!withinPath(path.resolve(file),root))return {issue:'typescript-buildinfo-outside-repository'};
|
|
100
|
+
const relative=path.relative(root,path.resolve(file)).split(path.sep).join('/');
|
|
101
|
+
if(!relative.endsWith('.tsbuildinfo')||/[*?[\]{}()!+@,]/.test(relative))return {issue:'typescript-buildinfo-name-unsupported'};
|
|
102
|
+
return {file:relative};
|
|
103
|
+
}
|
|
104
|
+
// The plan's build-info file, or null. The compiler is loaded only when the
|
|
105
|
+
// root config sets incremental or extends another config.
|
|
106
|
+
export function directBuildInfo(root,script) {
|
|
107
|
+
const config=path.join(root,'tsconfig.json');
|
|
108
|
+
let raw;try{raw=fs.readFileSync(config,'utf8');}catch{return null;}
|
|
109
|
+
if(!/"(?:incremental|extends)"\s*:/.test(raw))return null;
|
|
110
|
+
const compiler=directProject(root,script,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
|
|
111
|
+
const parsed=ts.getParsedCommandLineOfConfigFile(config,{},{...ts.sys,onUnRecoverableConfigFileDiagnostic(){}});
|
|
112
|
+
return parsed?.options?.composite?null:buildInfoFor(ts,parsed?.options,root).file??null;
|
|
84
113
|
}
|
|
85
114
|
// The probe value the daemon stores for one direct probe's checkpoint output.
|
|
86
115
|
export function directProbeValue(argv,output,stderrHash){
|
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/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,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,9 +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');
|
|
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');
|
|
63
85
|
// Every hashed configuration must also be a certified question.
|
|
64
86
|
const queries=captured?.queries&&[...configReads].every(file=>
|
|
65
87
|
captured.queries.some(([kind,read])=>kind==='readFile'&&read===file))?captured.queries:null;
|
|
66
88
|
return {configs:[...configReads].sort().map(file=>[file,digestFile(file)]),
|
|
67
|
-
files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries
|
|
89
|
+
files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries,
|
|
90
|
+
...(buildInfo?{buildInfo}:{})};
|
|
68
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) {
|
|
@@ -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
|
}
|