@redue/cli 0.1.0-alpha.12 → 0.1.0-alpha.14
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 +57 -0
- package/README.md +65 -5
- package/bin/redue.mjs +19 -6
- package/docs/user/json.md +1 -0
- package/docs/user/pilot.md +3 -3
- package/docs/user/supported-projects.md +4 -1
- package/docs/user/tests-direct.md +106 -0
- package/package.json +1 -1
- package/src/agent-integration.mjs +27 -16
- package/src/claude-hook.mjs +17 -7
- package/src/cli.mjs +96 -20
- package/src/daemon.mjs +57 -12
- package/src/declared-plan.mjs +3 -2
- package/src/direct-run-certificate.mjs +1 -1
- package/src/installed-inputs.mjs +3 -1
- package/src/linux-inotify.py +29 -10
- package/src/node-onboarding.mjs +15 -1
- package/src/pnpm-inputs.mjs +3 -2
- package/src/probe-deadline.mjs +37 -0
- package/src/test-direct.mjs +365 -0
- package/src/test-recorder.cjs +318 -0
- package/src/typescript-input-facts.mjs +3 -2
- package/src/typescript-list.mjs +3 -2
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/cli.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {builtInProbe,probeFloorMs,publishedDeadline} from './probe-deadline.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
4
5
|
import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
|
|
5
6
|
import {runFactChange} from './typescript-list.mjs';
|
|
7
|
+
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testProject,testEnvironmentIssue,testGuardConfig,testRunGuard,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import net from 'node:net';
|
|
8
10
|
import os from 'node:os';
|
|
@@ -18,7 +20,7 @@ import {createHash,randomUUID} from 'node:crypto';
|
|
|
18
20
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
19
21
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
20
22
|
import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
|
|
21
|
-
import {atomicJson,commitReceipt,receiptRevision} from './state-store.mjs';
|
|
23
|
+
import {atomicJson,commitReceipt,readReceipts,receiptRevision} from './state-store.mjs';
|
|
22
24
|
import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
|
|
23
25
|
import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
24
26
|
import {renderRun} from './presentation.mjs';
|
|
@@ -43,7 +45,14 @@ timing('cli.config_resolution',configStarted);
|
|
|
43
45
|
// A native Windows decision reconciles declared contents instead of trusting
|
|
44
46
|
// watcher history. The measured 6k-file pnpm workspace can legitimately need
|
|
45
47
|
// more than the former 15 s default; uncertainty still wins on expiry.
|
|
46
|
-
|
|
48
|
+
// A decision can include a full probe, whose deadline the observer publishes.
|
|
49
|
+
// Read when a wait starts, so a deadline the previous decision raised counts.
|
|
50
|
+
const decisionBaseMs=process.platform==='win32'?30000:15000;
|
|
51
|
+
function publishedProbeDeadline() {
|
|
52
|
+
try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
|
|
53
|
+
catch{return 0;}
|
|
54
|
+
}
|
|
55
|
+
const decisionDeadline=()=>Math.max(decisionBaseMs,publishedProbeDeadline()+10000);
|
|
47
56
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
48
57
|
function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
|
|
49
58
|
return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
@@ -55,7 +64,13 @@ function pathExecutableIdentity(name) {
|
|
|
55
64
|
catch{}
|
|
56
65
|
return digest('<unavailable>');
|
|
57
66
|
}
|
|
58
|
-
|
|
67
|
+
// The variables each test check's last run read directly (see testRunGuard).
|
|
68
|
+
const readsFile=path.join(state,'environment-reads-v1.json');
|
|
69
|
+
function environmentReads() {
|
|
70
|
+
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
71
|
+
}
|
|
72
|
+
function contextHashes() {const reads=config.checks.some(check=>check.qualification===testsQualification)?environmentReads():{};
|
|
73
|
+
return Object.fromEntries(config.checks.map(check=>[
|
|
59
74
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
60
75
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
61
76
|
...(check.environment?.prefixes||[]).map(prefix=>[
|
|
@@ -65,15 +80,20 @@ function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
|
|
|
65
80
|
.sort(([a],[b])=>a.localeCompare(b))))]),
|
|
66
81
|
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
67
82
|
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
68
|
-
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[])
|
|
83
|
+
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
|
|
84
|
+
// The variables the last test run read directly, compared here: which
|
|
85
|
+
// list was compared, and the first variable whose value now differs.
|
|
86
|
+
// Control requests are small, so the values stay out of them.
|
|
87
|
+
...(reads[check.name]?[['__redue_env_reads__',digest(JSON.stringify(reads[check.name]))],
|
|
88
|
+
['__redue_env_changed__',Object.keys(reads[check.name]).find(key=>
|
|
89
|
+
(process.env[key]===undefined?'<unset>':digest(process.env[key]))!==reads[check.name][key])||'']]:[])])]));}
|
|
69
90
|
function probeHash(plan) {
|
|
70
91
|
const started=performance.now();
|
|
71
92
|
const values=[];
|
|
93
|
+
// The observer's current deadline, so a large project's probe is not cut short.
|
|
94
|
+
const timeout=Math.max(probeFloorMs(plan.qualification),publishedProbeDeadline());
|
|
72
95
|
for(const argv of plan.probes||[]) {
|
|
73
|
-
const timeout
|
|
74
|
-
(process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
|
|
75
|
-
.includes(plan.qualification))?10000:5000;
|
|
76
|
-
const out=spawnSync(argv[0],argv.slice(1),{cwd:plan.cwd,
|
|
96
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(builtInProbe(argv)?[`--redue-deadline=${timeout}`]:[])],{cwd:plan.cwd,
|
|
77
97
|
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:process.env});
|
|
78
98
|
if(out.error||out.signal||out.status!==0){
|
|
79
99
|
timing('cli.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
|
|
@@ -210,7 +230,7 @@ async function execute() {
|
|
|
210
230
|
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
211
231
|
timing('cli.context_before',contextStarted);
|
|
212
232
|
const
|
|
213
|
-
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||
|
|
233
|
+
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
|
|
214
234
|
let before=null,after=null;
|
|
215
235
|
// Expensive state probes can take seconds. Keep the filesystem checkpoint
|
|
216
236
|
// adjacent to process launch so a late notification from earlier work
|
|
@@ -251,6 +271,19 @@ async function execute() {
|
|
|
251
271
|
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
252
272
|
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
253
273
|
...command.slice(1)]:command;
|
|
274
|
+
// A test run's processes record what the guard checks (test-recorder.cjs).
|
|
275
|
+
const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
|
|
276
|
+
let testRecord=null,launchEnv=process.env;
|
|
277
|
+
if(testRun){
|
|
278
|
+
const environmentIssue=testEnvironmentIssue();
|
|
279
|
+
if(environmentIssue)captureIssues.push(environmentIssue);
|
|
280
|
+
testRecord=path.join(state,`test-record-${runId}`);
|
|
281
|
+
fs.mkdirSync(testRecord,{mode:0o700});
|
|
282
|
+
const guardFile=path.join(testRecord,'guard.json');
|
|
283
|
+
fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root)),{mode:0o600});
|
|
284
|
+
launchEnv=recordedEnvironment(guardFile);
|
|
285
|
+
}
|
|
286
|
+
let runnerPid=null;
|
|
254
287
|
const record=[];
|
|
255
288
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
256
289
|
// are still arriving. Fold callbacks delivered before process launch into
|
|
@@ -274,11 +307,11 @@ async function execute() {
|
|
|
274
307
|
try{runLock.phase('launching');
|
|
275
308
|
const launch=windowsLaunch(launched);
|
|
276
309
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
277
|
-
stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:
|
|
310
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
|
|
278
311
|
detached:process.platform==='linux',...launch.options});
|
|
279
312
|
child.stdio[3]?.on('data',chunk=>record.push(chunk));}
|
|
280
313
|
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
281
|
-
activeChild=child;runLock.phase('running',child.pid??null);
|
|
314
|
+
activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
|
|
282
315
|
mark('cli.child_started',{childPid:child.pid});
|
|
283
316
|
if(requestedSignal)forward(requestedSignal);
|
|
284
317
|
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
@@ -319,18 +352,44 @@ async function execute() {
|
|
|
319
352
|
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
320
353
|
probeAfter=certificate.value;
|
|
321
354
|
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
355
|
+
let guard=null;
|
|
356
|
+
if(testRecord){
|
|
357
|
+
let coverage=null;try{coverage=coverageReadable(root,testProject(root,selected.script));}catch{}
|
|
358
|
+
guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
|
|
359
|
+
runner:runnerPid,environment:selected.environment||{},coverage});
|
|
360
|
+
runFacts=guard.runFacts;
|
|
361
|
+
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
362
|
+
}
|
|
322
363
|
// The daemon reads the recording from owned state; control requests are small.
|
|
323
364
|
const recordFile=recording&&path.join(state,`run-record-${recording}.json`);
|
|
324
365
|
if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
|
|
325
366
|
const endSnapshotStarted=performance.now();
|
|
326
367
|
if(!unobservedDirectRun){
|
|
327
368
|
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
328
|
-
...(
|
|
369
|
+
...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
370
|
+
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
371
|
+
// The start checkpoint's probe may have raised the deadline.
|
|
372
|
+
Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
|
|
329
373
|
catch{captureIssues.push('end observation unavailable');}
|
|
330
374
|
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
331
375
|
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
332
376
|
}
|
|
333
377
|
const end=after?.snapshots?.[name];
|
|
378
|
+
// A test run's own outputs: git-ignored files it created without reading
|
|
379
|
+
// what was there (test/tmp). When they are the only observed change, the
|
|
380
|
+
// run is kept with the end checkpoint, which includes them.
|
|
381
|
+
let outputs=null;
|
|
382
|
+
let eventsExplained=false;
|
|
383
|
+
if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
|
|
384
|
+
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
385
|
+
// A directory event is not an input change; a change to its files shows
|
|
386
|
+
// in the end checkpoint's file map, which is compared too. A link is kept.
|
|
387
|
+
const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
|
|
388
|
+
const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
|
|
389
|
+
const touched=[...new Set([...changed,...events])].sort();
|
|
390
|
+
const ignored=touched.length&&touched.every(guard.isOutput)?gitIgnored(root,touched):new Set();
|
|
391
|
+
if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
392
|
+
}
|
|
334
393
|
// The end certificate, recorded or probed, must cover what this compile received.
|
|
335
394
|
// With no input event, gap or content change in between, it also holds at the start.
|
|
336
395
|
// A run checked by full probes is compared with it too, for its run facts.
|
|
@@ -361,16 +420,32 @@ async function execute() {
|
|
|
361
420
|
captureIssues.push('observer generation changed during execution');
|
|
362
421
|
if(snap?.planGeneration!==end?.planGeneration)
|
|
363
422
|
captureIssues.push('input plan rebuilt during execution');
|
|
364
|
-
if(snap?.inputEventSerial!==end?.inputEventSerial)
|
|
423
|
+
if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained)
|
|
365
424
|
captureIssues.push('declared input event occurred during execution');
|
|
366
425
|
// A recorded certificate changes only the probe part of the fingerprint.
|
|
367
|
-
if(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
368
|
-
snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
|
|
426
|
+
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
427
|
+
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
369
428
|
const stable=captureIssues.length===0;
|
|
429
|
+
// Two runs on the same inputs, environment and plan that disagree make the
|
|
430
|
+
// check nondeterministic until its inputs change.
|
|
431
|
+
let nondeterministic=false;
|
|
432
|
+
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
433
|
+
if(testRun&&stable&&result){
|
|
434
|
+
const prior=readReceipts(state).checks[name];
|
|
435
|
+
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
436
|
+
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
437
|
+
prior.fingerprint===((recording||outputs?end:snap)?.fingerprint??null)&&
|
|
438
|
+
JSON.stringify(declared(prior.environmentHashes))===JSON.stringify(declared(environmentBefore))&&
|
|
439
|
+
JSON.stringify(prior.runFacts||[])===JSON.stringify([...runFacts].sort())&&
|
|
440
|
+
JSON.stringify(prior.environmentReads||{})===JSON.stringify(guard?.environmentReads||{})&&
|
|
441
|
+
(prior.result!==result||prior.nondeterministic===true));
|
|
442
|
+
}
|
|
370
443
|
// A recorded run's receipt carries its run facts, even none; observers that
|
|
371
444
|
// predate run facts do not reuse version 2.
|
|
372
|
-
const receipt={result,invocation,target,stable,fingerprintVersion:recorded?2:1,
|
|
373
|
-
...(recorded?{runFacts}:{}),
|
|
445
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
|
|
446
|
+
...(recorded||testRun?{runFacts}:{}),
|
|
447
|
+
...(testRun?{verificationRecipe:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
448
|
+
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
374
449
|
resolutionLinks:plan.resolutionLinks||[],
|
|
375
450
|
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
|
376
451
|
checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
|
|
@@ -378,13 +453,14 @@ async function execute() {
|
|
|
378
453
|
...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
|
|
379
454
|
startInputEventSerial:snap?.inputEventSerial??null,
|
|
380
455
|
endInputEventSerial:end?.inputEventSerial??null}: {})},
|
|
381
|
-
planId:snap?.planId??plan.id,fingerprint:(recording?end:snap)?.fingerprint??null,
|
|
456
|
+
planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
|
|
382
457
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
383
458
|
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
384
|
-
files:snap?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
459
|
+
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
385
460
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
386
461
|
const persistenceStarted=performance.now();
|
|
387
462
|
commitReceipt(state,name,receipt);
|
|
463
|
+
if(testRun){const reads=environmentReads();reads[name]=receipt.environmentReads;atomicJson(readsFile,{schema:1,checks:reads});}
|
|
388
464
|
timing('cli.receipt_persistence',persistenceStarted);
|
|
389
465
|
const reloadStarted=performance.now();
|
|
390
466
|
try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
|
|
@@ -489,7 +565,7 @@ async function main(){
|
|
|
489
565
|
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
490
566
|
}
|
|
491
567
|
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
492
|
-
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||
|
|
568
|
+
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadline()));
|
|
493
569
|
// A client upgrade can encounter an older running observer. Its successful
|
|
494
570
|
// control response does not establish that it evaluated the latest receipt.
|
|
495
571
|
if(value.receipt_revision===null){
|
package/src/daemon.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './sta
|
|
|
18
18
|
import {history,closeHistory} from './observation.mjs';
|
|
19
19
|
import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
|
|
20
20
|
import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
|
|
21
|
+
import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
|
|
21
22
|
import {statusSummary} from './cached-state.mjs';
|
|
22
23
|
import {directProject,directProbeValue,reviewedTypeScriptVersions} from './direct-typescript.mjs';
|
|
23
24
|
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
@@ -49,6 +50,9 @@ let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, pla
|
|
|
49
50
|
let decisionRequests=0;
|
|
50
51
|
let decisionSequence=0;
|
|
51
52
|
let inputEventSerial=new Map();
|
|
53
|
+
// The last input-event paths per check, with their serials, so a test run can
|
|
54
|
+
// show that every event during it was one of its own outputs.
|
|
55
|
+
const inputEventPaths=new Map(),eventPathLimit=10000;
|
|
52
56
|
let diagnosticInputEvents=0;
|
|
53
57
|
let synchronizing=null;
|
|
54
58
|
let installedRecheck=null;
|
|
@@ -153,6 +157,14 @@ function reuseCertificate(row,key,allowReuse,forceFull,checked) {
|
|
|
153
157
|
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
154
158
|
return null;
|
|
155
159
|
}
|
|
160
|
+
// The current full-probe deadline for a check; see probe-deadline.mjs. Hints
|
|
161
|
+
// are kept per check name, so a plan rebuild (each Windows decision) keeps them.
|
|
162
|
+
const probeHints=new Map();
|
|
163
|
+
function fullProbeDeadline(row) {
|
|
164
|
+
const run=receipts[row.plan.name]?.invocation;
|
|
165
|
+
return probeDeadline({floor:probeFloorMs(row.plan.qualification),hint:probeHints.get(row.plan.name),
|
|
166
|
+
runMs:run?.status==='exited'?run.durationMs:0});
|
|
167
|
+
}
|
|
156
168
|
function probe(row,allowReuse=false,{launch=true}={}) {
|
|
157
169
|
const started=performance.now();
|
|
158
170
|
const supported=new Map([
|
|
@@ -205,18 +217,21 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
205
217
|
row.probeCache=null;
|
|
206
218
|
}
|
|
207
219
|
const values=[];let checkpoint=null;
|
|
220
|
+
// The slowest of this check's probes sets the hint for all of them.
|
|
221
|
+
const deadline=fullProbeDeadline(row);let hint=0;
|
|
222
|
+
const learn=()=>{if(hint)probeHints.set(row.plan.name,hint);};
|
|
208
223
|
for(const argv of row.plan.probes) {
|
|
209
|
-
|
|
210
|
-
// Native NTFS runs have exceeded the generic 5 s cap under load, despite
|
|
211
|
-
// completing normally and with the duplicated work already removed.
|
|
212
|
-
const timeout=row.plan.qualification==='pnpm-tsc-v1'||
|
|
213
|
-
(process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
|
|
214
|
-
.includes(row.plan.qualification))?10000:5000;
|
|
224
|
+
const timeout=deadline,launched=performance.now();
|
|
215
225
|
mark('daemon.probe_launch',{mode:'full',reason:fullReason,deadlineMs:timeout});
|
|
216
|
-
|
|
226
|
+
// A built-in probe gets its deadline, so its inner compiler calls share it.
|
|
227
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[]),
|
|
228
|
+
...(builtIn?[`--redue-deadline=${timeout}`]:[])],{cwd:row.plan.cwd,
|
|
217
229
|
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:traceEnvironment()});
|
|
218
230
|
mark('daemon.probe_complete',{mode:'full',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
|
|
231
|
+
const timedOut=out.error?.code==='ETIMEDOUT';
|
|
232
|
+
if(!out.error||timedOut)hint=Math.max(hint,nextProbeHint({durationMs:performance.now()-launched,deadlineMs:timeout,timedOut}));
|
|
219
233
|
if(out.error||out.signal||out.status!==0) {
|
|
234
|
+
learn();
|
|
220
235
|
if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
|
|
221
236
|
row.probeHash=null;row.probeAt=0;
|
|
222
237
|
const code=['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
@@ -280,6 +295,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
|
|
|
280
295
|
}
|
|
281
296
|
values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
|
|
282
297
|
}
|
|
298
|
+
learn();
|
|
283
299
|
const value=sha(JSON.stringify(values));
|
|
284
300
|
if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
|
|
285
301
|
row.revision++;row.lastReason='declared state probe changed or recovered';}
|
|
@@ -383,6 +399,14 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
383
399
|
}
|
|
384
400
|
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
385
401
|
item.reason='declared execution environment changed';
|
|
402
|
+
// Every variable a test run read directly must have the same value. The
|
|
403
|
+
// caller compares them and names the list it used; another list confirms nothing.
|
|
404
|
+
else if(r.environmentReads&&contextHashes?.[name]&&
|
|
405
|
+
(contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))||contextHashes[name].__redue_env_changed__)){
|
|
406
|
+
if(contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))){
|
|
407
|
+
item.freshness='UNVERIFIED';item.reason='environment variables the last run read were not compared';}
|
|
408
|
+
else item.reason=`environment variable ${contextHashes[name].__redue_env_changed__} differs from the last run`;
|
|
409
|
+
}
|
|
386
410
|
else if(r.fingerprint!==combinedSummary(name).fingerprint) {
|
|
387
411
|
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
388
412
|
if(row.staleReasonKey!==key){
|
|
@@ -413,6 +437,12 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
413
437
|
} else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
|
|
414
438
|
item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
|
|
415
439
|
} else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
|
|
440
|
+
// A recorded test run that used something REDUE does not observe, or
|
|
441
|
+
// whose result differed from an earlier run on the same inputs.
|
|
442
|
+
} else if(r.guardIssues?.length){item.freshness='UNVERIFIED';
|
|
443
|
+
item.reason=`the run used inputs REDUE does not observe: ${r.guardIssues.slice(0,3).join('; ')}`+
|
|
444
|
+
(r.guardIssues.length>3?`; and ${r.guardIssues.length-3} more`:'');
|
|
445
|
+
} else if(r.nondeterministic){item.freshness='UNVERIFIED';item.reason='runs on these exact inputs had different results';
|
|
416
446
|
} else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
|
|
417
447
|
item.reuse_eligible=result==='PASS';}
|
|
418
448
|
return item;
|
|
@@ -478,7 +508,10 @@ function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem
|
|
|
478
508
|
// wrapper may persist its outcome even when its control connection is denied.
|
|
479
509
|
refreshReceipts();
|
|
480
510
|
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
|
|
511
|
+
// A decision may run every check's probes in turn: callers wait for all of them.
|
|
512
|
+
const probing=[...(index?.checks.values()||[])].filter(row=>row.plan.probes.length);
|
|
481
513
|
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
|
|
514
|
+
...(probing.length?{probe_deadline_ms:probing.reduce((sum,row)=>sum+fullProbeDeadline(row)*row.plan.probes.length,0)}:{}),
|
|
482
515
|
observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
|
|
483
516
|
generation,observed_at:lastObservation,
|
|
484
517
|
events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
|
|
@@ -746,8 +779,15 @@ function markInputEvent(name,rel,type,replay) {
|
|
|
746
779
|
// macOS delivers each edit twice: live, and again from history replay. The
|
|
747
780
|
// live copy can arrive late, inside a later run. With history, count only the
|
|
748
781
|
// replay copy: the history cursor places it in the right interval.
|
|
749
|
-
if(!hasHistory||historyDisabled||replay)
|
|
750
|
-
|
|
782
|
+
if(!hasHistory||historyDisabled||replay){
|
|
783
|
+
const serial=(inputEventSerial.get(name)||0)+1;
|
|
784
|
+
inputEventSerial.set(name,serial);
|
|
785
|
+
// Kept only for test checks; trimmed in batches.
|
|
786
|
+
if(index?.checks.get(name)?.plan.qualification==='npm-tests-direct-v1'){
|
|
787
|
+
const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
|
|
788
|
+
if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
751
791
|
// Disposable public CI only. Never enabled for normal/local repositories.
|
|
752
792
|
if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
|
|
753
793
|
event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
|
|
@@ -802,16 +842,16 @@ function externalNotification(entry,type,filename,replay=false) {
|
|
|
802
842
|
const rel=filename.toString().split(path.sep).join('/');
|
|
803
843
|
const absolute=path.join(entry.root,rel);
|
|
804
844
|
if(planning&&entry.index)for(const name of entry.index.candidates(rel))
|
|
805
|
-
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name
|
|
845
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
|
|
806
846
|
if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
|
|
807
847
|
if(candidateTriggerChanged(absolute))planDirty=true;
|
|
808
848
|
entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
|
|
809
849
|
pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
|
|
810
850
|
if(entry.index) {
|
|
811
851
|
for(const name of entry.index.candidates(rel))
|
|
812
|
-
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name
|
|
852
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
|
|
813
853
|
for(const [prefix,names] of entry.index.prefixes)
|
|
814
|
-
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name
|
|
854
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
|
|
815
855
|
}
|
|
816
856
|
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
817
857
|
publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
@@ -1099,6 +1139,11 @@ async function request(message) {
|
|
|
1099
1139
|
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
1100
1140
|
planGeneration:row.plan.id,
|
|
1101
1141
|
inputEventSerial:inputEventSerial.get(name)||0,
|
|
1142
|
+
// Complete only when the oldest kept event follows the given serial.
|
|
1143
|
+
...(Number.isSafeInteger(message.sinceSerial)?(()=>{const list=inputEventPaths.get(name)||[],
|
|
1144
|
+
since=message.sinceSerial,later=list.filter(([serial])=>serial>since);
|
|
1145
|
+
return {inputEventPaths:later.map(([,rel])=>rel),
|
|
1146
|
+
inputEventPathsComplete:(inputEventSerial.get(name)||0)-since===later.length};})():{}),
|
|
1102
1147
|
probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
|
|
1103
1148
|
observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
|
|
1104
1149
|
// Export only a currently guarded, check-specific discovery certificate.
|
package/src/declared-plan.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {createHash} from 'node:crypto';
|
|
|
6
6
|
import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
|
|
7
7
|
import {qualifyTypeScript} from './typescript-qualification.mjs';
|
|
8
8
|
import {directBuildInfo} from './direct-typescript.mjs';
|
|
9
|
+
import {qualifyTests,testsQualification} from './test-direct.mjs';
|
|
9
10
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
10
11
|
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
11
12
|
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
@@ -80,8 +81,8 @@ export function discoverDeclared(config) {
|
|
|
80
81
|
const installed=resolveDeclaredInstalled(root,
|
|
81
82
|
[...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
|
|
82
83
|
timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
|
|
83
|
-
const automatic=
|
|
84
|
-
{workspace,issue:workspaceIssue});
|
|
84
|
+
const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
|
|
85
|
+
qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
|
|
85
86
|
const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
|
|
86
87
|
...(workspace?.generated||[]),...excluded])].sort();
|
|
87
88
|
const additivePatterns=[...new Set([...(selected.generatedInputs||[]),
|
|
@@ -115,7 +115,7 @@ const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
|
|
|
115
115
|
file!==compiler.entry&&file.toLowerCase()===compiler.entry.toLowerCase();
|
|
116
116
|
// The certificate question that asks again what a run's stat, realpath or
|
|
117
117
|
// package.json read answered, in queryAnswer's terms; null when it cannot be asked again.
|
|
118
|
-
function runFact(kind,file,answer) {
|
|
118
|
+
export function runFact(kind,file,answer) {
|
|
119
119
|
if(kind==='stat')return answer===null||answer==='f'?['fileExists',file,answer&&true]:
|
|
120
120
|
answer==='d'?['directoryExists',file,true]:null;
|
|
121
121
|
if(kind==='realpath')return answer===null||typeof answer==='string'?['realpath',file,answer]:null;
|
package/src/installed-inputs.mjs
CHANGED
|
@@ -99,7 +99,9 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
|
|
|
99
99
|
else {if(!external.has(boundary))external.set(boundary,new Set());
|
|
100
100
|
external.get(boundary).add(mapped);}
|
|
101
101
|
if(!negative)roots.add(resolved.physical);
|
|
102
|
-
|
|
102
|
+
// An exclusion maps to the same pattern whether or not its path exists, so
|
|
103
|
+
// the plan stays the same when a run creates it (a runner cache).
|
|
104
|
+
if(!negative)result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
|
|
103
105
|
links:resolved.links.map(link=>link.path)});
|
|
104
106
|
}
|
|
105
107
|
result.internalPatterns=[...internal].sort();
|
package/src/linux-inotify.py
CHANGED
|
@@ -87,12 +87,21 @@ def main(root):
|
|
|
87
87
|
continue
|
|
88
88
|
wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
|
|
89
89
|
if wd < 0:
|
|
90
|
-
|
|
90
|
+
error = ctypes.get_errno()
|
|
91
|
+
# A new directory deleted before its watch: nothing to watch,
|
|
92
|
+
# and its parent reports the deletion.
|
|
93
|
+
if current != root and error in (errno.ENOENT, errno.ENOTDIR):
|
|
94
|
+
continue
|
|
95
|
+
raise OSError(error, 'inotify_add_watch failed')
|
|
91
96
|
watches[wd] = current
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
try:
|
|
98
|
+
with os.scandir(current) as entries:
|
|
99
|
+
for entry in entries:
|
|
100
|
+
if entry.is_dir(follow_symlinks=False):
|
|
101
|
+
stack.append(entry.path)
|
|
102
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
103
|
+
if current == root:
|
|
104
|
+
raise
|
|
96
105
|
|
|
97
106
|
def drain():
|
|
98
107
|
while True:
|
|
@@ -129,6 +138,13 @@ def main(root):
|
|
|
129
138
|
target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
|
|
130
139
|
if in_git(target):
|
|
131
140
|
continue
|
|
141
|
+
if mask & IN_DELETE_SELF and parent != root:
|
|
142
|
+
# A watched directory was deleted: its IN_IGNORED follows
|
|
143
|
+
# and is expected. The engine rescans the path.
|
|
144
|
+
del watches[wd]
|
|
145
|
+
intentional_removals.add(wd)
|
|
146
|
+
emit('event', type='delete', path=parent)
|
|
147
|
+
continue
|
|
132
148
|
if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
|
|
133
149
|
emit('gap', reason='inotify_watch_lost')
|
|
134
150
|
return False
|
|
@@ -138,11 +154,14 @@ def main(root):
|
|
|
138
154
|
except OSError as error:
|
|
139
155
|
emit('gap', reason='inotify_watch_add_failed', errno=error.errno)
|
|
140
156
|
return False
|
|
141
|
-
# Contents can appear before
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
# Contents can appear before the new directory has its
|
|
158
|
+
# watch. The watches exist now, so later changes raise
|
|
159
|
+
# events; the engine rescans the directory on this event
|
|
160
|
+
# and finds what came before.
|
|
161
|
+
emit('event', type='create', path=target)
|
|
162
|
+
continue
|
|
163
|
+
# A moved-away directory keeps watches under its old paths.
|
|
164
|
+
if (mask & IN_ISDIR) and (mask & IN_MOVED_FROM):
|
|
146
165
|
emit('gap', reason='inotify_directory_removed')
|
|
147
166
|
return False
|
|
148
167
|
event = ('delete' if mask & (IN_DELETE | IN_MOVED_FROM) else
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {directProject,directQualification} from './direct-typescript.mjs';
|
|
2
|
+
import {testProject,testRecipeScript,testCheckInputs,testsQualification} from './test-direct.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import {spawnSync} from 'node:child_process';
|
|
@@ -161,7 +162,7 @@ function workspaceManifests(root,patterns) {
|
|
|
161
162
|
return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
|
|
162
163
|
}
|
|
163
164
|
export function discoverNodeProject(root,{recipe=null}={}) {
|
|
164
|
-
if(recipe
|
|
165
|
+
if(recipe&&!['typescript-direct','tests-direct'].includes(recipe))throw Error('unknown recipe; use typescript-direct or tests-direct');
|
|
165
166
|
root=realObservedPath(root);
|
|
166
167
|
const packageFile=path.join(root,'package.json');
|
|
167
168
|
if(!exists(packageFile))throw Error('run init from a repository containing package.json');
|
|
@@ -197,6 +198,19 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
197
198
|
reason:`explicit local TypeScript ${compiler.version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
|
|
198
199
|
}catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
|
|
199
200
|
}
|
|
201
|
+
if(recipe==='tests-direct'&&kind==='test'){
|
|
202
|
+
try{
|
|
203
|
+
if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('test recipe requires a supported single-package npm checkout');
|
|
204
|
+
const chosen=testRecipeScript(scripts);
|
|
205
|
+
if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
|
|
206
|
+
if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
|
|
207
|
+
const project=testProject(root,chosen.script);
|
|
208
|
+
Object.assign(check,{script:chosen.script,command:['@node',`@test-runner:${project.runner}`,...project.args],cwd:'.',
|
|
209
|
+
...testCheckInputs(project)});
|
|
210
|
+
classification={level:'ready',qualification:testsQualification,
|
|
211
|
+
reason:`explicit local ${project.runner} ${project.version} run of script ${JSON.stringify(chosen.script)}; the whole checkout is its input; npm and lifecycle scripts are not executed`};
|
|
212
|
+
}catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
|
|
213
|
+
}
|
|
200
214
|
if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
|
|
201
215
|
/^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(scripts[script].trim())){
|
|
202
216
|
try{const contract=pnpmTypecheckInputs(root,'.',script);
|
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
|
+
}
|