@redue/cli 0.1.0-alpha.13 → 0.1.0-alpha.15

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
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
6
  import {runFactChange} from './typescript-list.mjs';
7
+ import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testProject,testEnvironmentIssue,testGuardConfig,testRunGuard,testsInterpretation,testsQualification} from './test-direct.mjs';
7
8
  import path from 'node:path';
8
9
  import net from 'node:net';
9
10
  import os from 'node:os';
@@ -19,7 +20,7 @@ import {createHash,randomUUID} from 'node:crypto';
19
20
  import {assertOwnedStatePlacement} from './owned-state.mjs';
20
21
  import {permittedRoots} from './installed-inputs.mjs';
21
22
  import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
22
- import {atomicJson,commitReceipt,receiptRevision} from './state-store.mjs';
23
+ import {atomicJson,commitReceipt,readReceipts,receiptRevision} from './state-store.mjs';
23
24
  import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
24
25
  import {readCachedStatus,unavailableCached} from './cached-state.mjs';
25
26
  import {renderRun} from './presentation.mjs';
@@ -63,7 +64,13 @@ function pathExecutableIdentity(name) {
63
64
  catch{}
64
65
  return digest('<unavailable>');
65
66
  }
66
- 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=>[
67
74
  check.name,Object.fromEntries([...(check.environment?.variables||[])
68
75
  .map(key=>[key,digest(process.env[key]??'<unset>')]),
69
76
  ...(check.environment?.prefixes||[]).map(prefix=>[
@@ -73,7 +80,13 @@ function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
73
80
  .sort(([a],[b])=>a.localeCompare(b))))]),
74
81
  ...(check.environment?.pathExecutables||[]).map(name=>[
75
82
  `__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
76
- ...(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])||'']]:[])])]));}
77
90
  function probeHash(plan) {
78
91
  const started=performance.now();
79
92
  const values=[];
@@ -258,6 +271,19 @@ async function execute() {
258
271
  const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
259
272
  const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
260
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;
261
287
  const record=[];
262
288
  // A Windows full reconciliation may finish while queued watcher callbacks
263
289
  // are still arriving. Fold callbacks delivered before process launch into
@@ -281,11 +307,11 @@ async function execute() {
281
307
  try{runLock.phase('launching');
282
308
  const launch=windowsLaunch(launched);
283
309
  child=spawn(launch.file,launch.args,{cwd:plan.cwd,
284
- stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:process.env,
310
+ stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
285
311
  detached:process.platform==='linux',...launch.options});
286
312
  child.stdio[3]?.on('data',chunk=>record.push(chunk));}
287
313
  catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
288
- activeChild=child;runLock.phase('running',child.pid??null);
314
+ activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
289
315
  mark('cli.child_started',{childPid:child.pid});
290
316
  if(requestedSignal)forward(requestedSignal);
291
317
  child.on('error',e=>{spawnError=e.code||e.name;});
@@ -326,12 +352,21 @@ async function execute() {
326
352
  if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
327
353
  probeAfter=certificate.value;
328
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
+ }
329
363
  // The daemon reads the recording from owned state; control requests are small.
330
364
  const recordFile=recording&&path.join(state,`run-record-${recording}.json`);
331
365
  if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
332
366
  const endSnapshotStarted=performance.now();
333
367
  if(!unobservedDirectRun){
334
368
  try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
369
+ ...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
335
370
  ...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
336
371
  // The start checkpoint's probe may have raised the deadline.
337
372
  Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
@@ -340,6 +375,21 @@ async function execute() {
340
375
  timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
341
376
  }
342
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
+ }
343
393
  // The end certificate, recorded or probed, must cover what this compile received.
344
394
  // With no input event, gap or content change in between, it also holds at the start.
345
395
  // A run checked by full probes is compared with it too, for its run facts.
@@ -370,16 +420,32 @@ async function execute() {
370
420
  captureIssues.push('observer generation changed during execution');
371
421
  if(snap?.planGeneration!==end?.planGeneration)
372
422
  captureIssues.push('input plan rebuilt during execution');
373
- if(snap?.inputEventSerial!==end?.inputEventSerial)
423
+ if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained)
374
424
  captureIssues.push('declared input event occurred during execution');
375
425
  // A recorded certificate changes only the probe part of the fingerprint.
376
- if(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
377
- 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');
378
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
+ }
379
443
  // A recorded run's receipt carries its run facts, even none; observers that
380
444
  // predate run facts do not reuse version 2.
381
- const receipt={result,invocation,target,stable,fingerprintVersion:recorded?2:1,
382
- ...(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}:{})}:{}),
383
449
  resolutionLinks:plan.resolutionLinks||[],
384
450
  coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
385
451
  checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
@@ -387,13 +453,14 @@ async function execute() {
387
453
  ...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
388
454
  startInputEventSerial:snap?.inputEventSerial??null,
389
455
  endInputEventSerial:end?.inputEventSerial??null}: {})},
390
- planId:snap?.planId??plan.id,fingerprint:(recording?end:snap)?.fingerprint??null,
456
+ planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
391
457
  provider:plan.provider,environmentHashes:environmentBefore,
392
458
  ...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
393
- files:snap?.files??null,observerGeneration:before?.observation?.generation??null,
459
+ files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
394
460
  startRevision:snap?.revision??null,endRevision:end?.revision??null};
395
461
  const persistenceStarted=performance.now();
396
462
  commitReceipt(state,name,receipt);
463
+ if(testRun){const reads=environmentReads();reads[name]=receipt.environmentReads;atomicJson(readsFile,{schema:1,checks:reads});}
397
464
  timing('cli.receipt_persistence',persistenceStarted);
398
465
  const reloadStarted=performance.now();
399
466
  try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
package/src/daemon.mjs CHANGED
@@ -50,6 +50,9 @@ let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, pla
50
50
  let decisionRequests=0;
51
51
  let decisionSequence=0;
52
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;
53
56
  let diagnosticInputEvents=0;
54
57
  let synchronizing=null;
55
58
  let installedRecheck=null;
@@ -396,6 +399,14 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
396
399
  }
397
400
  else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
398
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
+ }
399
410
  else if(r.fingerprint!==combinedSummary(name).fingerprint) {
400
411
  const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
401
412
  if(row.staleReasonKey!==key){
@@ -426,6 +437,12 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
426
437
  } else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
427
438
  item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
428
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';
429
446
  } else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
430
447
  item.reuse_eligible=result==='PASS';}
431
448
  return item;
@@ -762,8 +779,15 @@ function markInputEvent(name,rel,type,replay) {
762
779
  // macOS delivers each edit twice: live, and again from history replay. The
763
780
  // live copy can arrive late, inside a later run. With history, count only the
764
781
  // replay copy: the history cursor places it in the right interval.
765
- if(!hasHistory||historyDisabled||replay)
766
- 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
+ }
767
791
  // Disposable public CI only. Never enabled for normal/local repositories.
768
792
  if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
769
793
  event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
@@ -818,16 +842,16 @@ function externalNotification(entry,type,filename,replay=false) {
818
842
  const rel=filename.toString().split(path.sep).join('/');
819
843
  const absolute=path.join(entry.root,rel);
820
844
  if(planning&&entry.index)for(const name of entry.index.candidates(rel))
821
- 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);
822
846
  if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
823
847
  if(candidateTriggerChanged(absolute))planDirty=true;
824
848
  entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
825
849
  pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
826
850
  if(entry.index) {
827
851
  for(const name of entry.index.candidates(rel))
828
- 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);
829
853
  for(const [prefix,names] of entry.index.prefixes)
830
- 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);
831
855
  }
832
856
  if(planDirty){healthy=false;reason='input plan possibly changed';}
833
857
  publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
@@ -1115,6 +1139,11 @@ async function request(message) {
1115
1139
  data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
1116
1140
  planGeneration:row.plan.id,
1117
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};})():{}),
1118
1147
  probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
1119
1148
  observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
1120
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);