@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/CHANGELOG.md +54 -0
- package/README.md +31 -2
- package/bin/redue.mjs +19 -6
- 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 +45 -18
- package/src/claude-hook.mjs +38 -11
- package/src/cli.mjs +79 -12
- package/src/daemon.mjs +34 -5
- 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/test-direct.mjs +365 -0
- package/src/test-recorder.cjs +318 -0
- package/src/typescript-list.mjs +3 -2
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
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);
|