@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/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
- const decisionDeadlineMs=process.platform==='win32'?30000:15000;
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
- function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
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=plan.qualification==='pnpm-tsc-v1'||
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||decisionDeadlineMs);
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:process.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
- ...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},captureTimeout);}
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||decisionDeadlineMs));
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
- // Supported TypeScript probes perform bounded input/toolchain hashing.
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
- const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[])],{cwd:row.plan.cwd,
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
- inputEventSerial.set(name,(inputEventSerial.get(name)||0)+1);
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,rel,type,replay);
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,rel,type,replay);
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,rel,type,replay);
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.
@@ -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=qualifyTypeScript(root,selected,
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;
@@ -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
- result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
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();
@@ -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
- raise OSError(ctypes.get_errno(), 'inotify_add_watch failed')
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
- with os.scandir(current) as entries:
93
- for entry in entries:
94
- if entry.is_dir(follow_symlinks=False):
95
- stack.append(entry.path)
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 their new directory receives
142
- # a watch. The engine reconciles instead of guessing.
143
- emit('gap', reason='inotify_new_directory')
144
- return False
145
- if (mask & IN_ISDIR) and (mask & (IN_DELETE | IN_MOVED_FROM)):
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
@@ -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&&recipe!=='typescript-direct')throw Error('unknown recipe; use typescript-direct');
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);
@@ -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
+ }