@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +491 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +132 -50
  4. package/docs/README.md +2 -1
  5. package/docs/user/agents.md +299 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +209 -0
  8. package/docs/user/getting-started.md +23 -2
  9. package/docs/user/json.md +2 -0
  10. package/docs/user/node-script.md +154 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/receipt-selection-notice.md +60 -0
  14. package/docs/user/supported-projects.md +56 -23
  15. package/docs/user/tests-direct.md +132 -0
  16. package/package.json +4 -3
  17. package/src/agent-integration.mjs +388 -0
  18. package/src/agent-policy.md +28 -0
  19. package/src/cached-state.mjs +46 -8
  20. package/src/claude-hook.mjs +449 -0
  21. package/src/cli.mjs +263 -40
  22. package/src/daemon.mjs +433 -94
  23. package/src/decision-profile.mjs +15 -1
  24. package/src/decision-validation.mjs +10 -0
  25. package/src/declared-plan.mjs +29 -6
  26. package/src/direct-run-certificate.mjs +252 -0
  27. package/src/direct-typescript-probe.mjs +24 -0
  28. package/src/direct-typescript.mjs +185 -0
  29. package/src/glob.mjs +8 -0
  30. package/src/history-worker.mjs +21 -13
  31. package/src/index.mjs +72 -19
  32. package/src/installed-inputs.mjs +7 -1
  33. package/src/linux-inotify.py +39 -10
  34. package/src/node-onboarding.mjs +172 -53
  35. package/src/node-script.mjs +364 -0
  36. package/src/npm-integrity.mjs +124 -0
  37. package/src/npm-typecheck-environment.mjs +36 -0
  38. package/src/npmrc.mjs +16 -0
  39. package/src/observation.mjs +34 -12
  40. package/src/path-identity.mjs +8 -0
  41. package/src/plan-worker.mjs +17 -17
  42. package/src/pnpm-inputs.mjs +114 -54
  43. package/src/pnpm-shim-templates.json +180 -0
  44. package/src/pnpm-shims.mjs +54 -0
  45. package/src/pnpm-typecheck-probe.mjs +11 -6
  46. package/src/presentation.mjs +9 -1
  47. package/src/probe-deadline.mjs +37 -0
  48. package/src/probe-result.mjs +7 -3
  49. package/src/project-location.mjs +34 -0
  50. package/src/run-recorder.cjs +91 -0
  51. package/src/state-store.mjs +37 -1
  52. package/src/test-direct.mjs +560 -0
  53. package/src/test-recorder.cjs +406 -0
  54. package/src/typescript-contract-probe.mjs +4 -187
  55. package/src/typescript-input-facts.mjs +92 -0
  56. package/src/typescript-invocation.mjs +92 -0
  57. package/src/typescript-list.mjs +108 -13
  58. package/src/typescript-qualification.mjs +29 -49
  59. package/src/workspace-manifests.mjs +24 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/cli.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import {builtInProbe,probeFloorMs,publishedDeadline} from './probe-deadline.mjs';
2
3
  import fs from 'node:fs';
4
+ import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
5
+ import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
+ import {queryAnswer,runFactChange} from './typescript-list.mjs';
7
+ import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
+ import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
3
9
  import path from 'node:path';
4
10
  import net from 'node:net';
5
11
  import os from 'node:os';
@@ -15,14 +21,16 @@ import {createHash,randomUUID} from 'node:crypto';
15
21
  import {assertOwnedStatePlacement} from './owned-state.mjs';
16
22
  import {permittedRoots} from './installed-inputs.mjs';
17
23
  import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
18
- import {atomicJson,commitReceipt} from './state-store.mjs';
19
- import {timing} from './decision-profile.mjs';
24
+ import {atomicJson,commitReceipt,readReceipts,receiptRevision} from './state-store.mjs';
25
+ import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
20
26
  import {readCachedStatus,unavailableCached} from './cached-state.mjs';
21
27
  import {renderRun} from './presentation.mjs';
22
28
 
29
+ mark('cli.loaded');
23
30
  const here=path.dirname(fileURLToPath(import.meta.url));
24
31
  const [configArg,action,name]=process.argv.slice(2);
25
32
  if(!configArg||!action)throw Error('internal usage: cli.mjs CONFIG ACTION [check]');
33
+ const configStarted=performance.now();
26
34
  const configFile=path.resolve(configArg), config=JSON.parse(fs.readFileSync(configFile));
27
35
  const maintenance=['stop','uninstall'].includes(action);
28
36
  const root=maintenance?path.resolve(config.root):realObservedPath(config.root), state=path.resolve(config.state),
@@ -34,10 +42,18 @@ if([path.parse(state).root,os.homedir(),path.dirname(os.homedir())]
34
42
  !/^(?:vstate|redue)-/.test(path.basename(state)))
35
43
  throw Error('state directory name must start with redue- (or legacy vstate-); use a dedicated directory outside the checkout');
36
44
  const ownerFile=path.join(state,'vstate-owner-v1.json');
45
+ timing('cli.config_resolution',configStarted);
37
46
  // A native Windows decision reconciles declared contents instead of trusting
38
47
  // watcher history. The measured 6k-file pnpm workspace can legitimately need
39
48
  // more than the former 15 s default; uncertainty still wins on expiry.
40
- const decisionDeadlineMs=process.platform==='win32'?30000:15000;
49
+ // A decision can include a full probe, whose deadline the observer publishes.
50
+ // Read when a wait starts, so a deadline the previous decision raised counts.
51
+ const decisionBaseMs=process.platform==='win32'?30000:15000;
52
+ function publishedProbeDeadline() {
53
+ try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
54
+ catch{return 0;}
55
+ }
56
+ const decisionDeadline=()=>Math.max(decisionBaseMs,publishedProbeDeadline()+10000);
41
57
  const digest=value=>createHash('sha256').update(value).digest('hex');
42
58
  function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
43
59
  return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
@@ -49,7 +65,14 @@ function pathExecutableIdentity(name) {
49
65
  catch{}
50
66
  return digest('<unavailable>');
51
67
  }
52
- function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
68
+ // The variables each test check's last run read directly (see testRunGuard).
69
+ const readsFile=path.join(state,'environment-reads-v1.json');
70
+ function environmentReads() {
71
+ try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
72
+ }
73
+ const recordedRecipes=[testsQualification,scriptQualification];
74
+ function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
75
+ return Object.fromEntries(config.checks.map(check=>[
53
76
  check.name,Object.fromEntries([...(check.environment?.variables||[])
54
77
  .map(key=>[key,digest(process.env[key]??'<unset>')]),
55
78
  ...(check.environment?.prefixes||[]).map(prefix=>[
@@ -59,15 +82,20 @@ function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
59
82
  .sort(([a],[b])=>a.localeCompare(b))))]),
60
83
  ...(check.environment?.pathExecutables||[]).map(name=>[
61
84
  `__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
62
- ...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[])])]));}
85
+ ...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
86
+ // The variables the last test run read directly, compared here: which
87
+ // list was compared, and the first variable whose value now differs.
88
+ // Control requests are small, so the values stay out of them.
89
+ ...(reads[check.name]?[['__redue_env_reads__',digest(JSON.stringify(reads[check.name]))],
90
+ ['__redue_env_changed__',Object.keys(reads[check.name]).find(key=>
91
+ (process.env[key]===undefined?'<unset>':digest(process.env[key]))!==reads[check.name][key])||'']]:[])])]));}
63
92
  function probeHash(plan) {
64
93
  const started=performance.now();
65
94
  const values=[];
95
+ // The observer's current deadline, so a large project's probe is not cut short.
96
+ const timeout=Math.max(probeFloorMs(plan.qualification),publishedProbeDeadline());
66
97
  for(const argv of plan.probes||[]) {
67
- const timeout=plan.qualification==='pnpm-tsc-v1'||
68
- (process.platform==='win32'&&['typescript-noemit-v1','yarn-workspace-tsc-v1']
69
- .includes(plan.qualification))?10000:5000;
70
- const out=spawnSync(argv[0],argv.slice(1),{cwd:plan.cwd,
98
+ const out=spawnSync(argv[0],[...argv.slice(1),...(builtInProbe(argv)?[`--redue-deadline=${timeout}`]:[])],{cwd:plan.cwd,
71
99
  timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:process.env});
72
100
  if(out.error||out.signal||out.status!==0){
73
101
  timing('cli.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
@@ -92,13 +120,13 @@ function owner(){
92
120
  }
93
121
  function call(message,timeout=5000) {
94
122
  return new Promise((resolve,reject)=>{
95
- let data='',done=false;const client=net.createConnection(socket);
96
- const timer=setTimeout(()=>{client.destroy();reject(Error('observer synchronization timeout'));},timeout);
97
- client.on('connect',()=>client.write(JSON.stringify(message)+'\n'));
123
+ let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
124
+ const timer=setTimeout(()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));},timeout);
125
+ client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
98
126
  client.on('data',chunk=>data+=chunk);
99
- client.on('end',()=>{clearTimeout(timer);if(done)return;done=true;
127
+ client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
100
128
  try{resolve(JSON.parse(data));}catch(e){reject(e);}});
101
- client.on('error',e=>{clearTimeout(timer);reject(e);});
129
+ client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
102
130
  });
103
131
  }
104
132
  async function decisionCall(message,timeout) {
@@ -144,11 +172,26 @@ async function decisionCall(message,timeout) {
144
172
  }
145
173
  }
146
174
  const cached=()=>readCachedStatus(state,config.checks);
175
+ async function testRunBoundary(phase) {
176
+ if(process.env.VSTATE_TEST_FAULTS!=='1'||process.env.VSTATE_TEST_RUN_BOUNDARY!==phase)return;
177
+ const release=path.join(state,'test-run-release');
178
+ fs.writeFileSync(path.join(state,'test-run-paused'),phase);
179
+ const until=Date.now()+5000;
180
+ while(!fs.existsSync(release)&&Date.now()<until)await new Promise(resolve=>setTimeout(resolve,10));
181
+ if(!fs.existsSync(release))throw Error('test run boundary was not released');
182
+ }
147
183
  async function execute() {
148
184
  const totalStarted=performance.now();
149
185
  const selected=config.checks.find(c=>c.name===name);
150
186
  if(!selected)throw Error(`unknown check ${name}`);
151
- const runLock=acquireRunLock(state);
187
+ // Recording uses existing state only. Never initialize or repair ownership
188
+ // as a fallback for unavailable observation.
189
+ const expectedOwner={schema:1,kind:'vstate-product-alpha',config:configFile,root,state};
190
+ const existingOwner=JSON.parse(fs.readFileSync(ownerFile,'utf8'));
191
+ if(JSON.stringify(existingOwner)!==JSON.stringify(expectedOwner))
192
+ throw Error('state ownership marker mismatch');
193
+ const lockStarted=performance.now(),runLock=acquireRunLock(state);
194
+ timing('cli.run_lock',lockStarted);
152
195
  const captureIssues=[];
153
196
  let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
154
197
  const forward=signal=>{requestedSignal=signal;
@@ -172,6 +215,7 @@ async function execute() {
172
215
  try {
173
216
  if(!fs.existsSync(path.join(state,'plans-v1.json')))
174
217
  throw Error('observer input plan is not ready; wait for redue status to report ready');
218
+ const resolutionStarted=performance.now();
175
219
  const plan=JSON.parse(fs.readFileSync(path.join(state,'plans-v1.json'))).plans[name];
176
220
  if(!plan)throw Error('selected input plan unavailable');
177
221
  if(plan.selectedConfigHash!==digest(JSON.stringify(selected)))
@@ -181,22 +225,79 @@ async function execute() {
181
225
  throw Error('invalid execution argv');
182
226
  if(digest(JSON.stringify(command))!==plan.commandIdentity.argvHash)
183
227
  throw Error('execution argv differs from discovered plan');
228
+ timing('cli.command_resolution',resolutionStarted);
184
229
  const runId=randomUUID();
185
230
  const contextStarted=performance.now();
186
231
  const environmentBefore=contextHashes()[name];
232
+ const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
187
233
  timing('cli.context_before',contextStarted);
188
234
  const
189
- captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadlineMs);
235
+ captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
190
236
  let before=null,after=null;
191
237
  // Expensive state probes can take seconds. Keep the filesystem checkpoint
192
238
  // adjacent to process launch so a late notification from earlier work
193
239
  // does not enter the execution interval before the check even starts.
194
- const probeBefore=probeHash({cwd:plan.cwd,probes:selected.probes||[],
195
- qualification:selected.qualification});
240
+ const callerCertificate=directRunCertificateAllowed(selected);
241
+ const probePlan={cwd:plan.cwd,probes:selected.probes||[],qualification:selected.qualification};
242
+ let probeBefore=callerCertificate?null:probeHash(probePlan);
196
243
  const startSnapshotStarted=performance.now();
197
- try{before=await call({action:'snapshot',name,contextHashes:contextHashes()},captureTimeout);}
198
- catch{captureIssues.push('start observation unavailable');}
244
+ try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
245
+ ...(callerCertificate?{runCertificate:1}:{})},captureTimeout);}
246
+ catch(error){captureIssues.push('start observation unavailable'+
247
+ (typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
199
248
  timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
249
+ // Missing discovery metadata can recover through a full probe. A missing
250
+ // healthy start boundary cannot: freeze this decision before any recovery.
251
+ // Only the reviewed direct recipe on macOS and Linux enters this shortcut.
252
+ const unobservedDirectRun=callerCertificate&&
253
+ before?.snapshots?.[name]?.observationHealthy!==true;
254
+ if(unobservedDirectRun){
255
+ captureIssues.push('qualification probes and end checkpoint not performed: no healthy start checkpoint');
256
+ mark('cli.run_validation_skipped',{reason:'start_observation_unavailable',probes:2,endCheckpoint:1});
257
+ }
258
+ await testRunBoundary('after-start-checkpoint');
259
+ // Start the observed interval before caller validation, not after it. Any
260
+ // intervening edit (even restored before launch) remains disqualifying.
261
+ // With a certificate, the compile records its own answers instead of two probes.
262
+ // Without one after an edit, the recording can become the certificate at the
263
+ // end checkpoint; that checkpoint probes in full if it cannot.
264
+ let certificate=null,recording=null;
265
+ if(callerCertificate&&!unobservedDirectRun){
266
+ certificate=directRunCertificate(plan,selected,before?.snapshots?.[name],root);
267
+ if(!certificate)recording=directRunRecording(plan,selected,before?.snapshots?.[name],root);
268
+ probeBefore=certificate?.value??(recording?null:probeHash(probePlan));
269
+ }
270
+ // The record arrives on fd 3, so the compiler's environment is unchanged.
271
+ // Every direct run is recorded: even one checked by full probes needs its
272
+ // lookups outside the certificate, which no probe makes.
273
+ const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
274
+ const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
275
+ ...command.slice(1)]:command;
276
+ // A test run's processes record what the guard checks (test-recorder.cjs),
277
+ // and so do a general recipe run's, through the package manager.
278
+ const general=selected.qualification===scriptQualification;
279
+ const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
280
+ let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
281
+ if(testRun){
282
+ // The package directory and installed node_modules, as the plan has them.
283
+ try{testFacts=general?scriptRunFacts(root,selected):testRunFacts(root,selected);}
284
+ catch(e){captureIssues.push(`${general?'script':'test'} run facts unavailable (${e.code||e.message})`);}
285
+ const environmentIssue=testEnvironmentIssue();
286
+ if(environmentIssue)captureIssues.push(environmentIssue);
287
+ testRecord=path.join(state,`test-record-${runId}`);
288
+ fs.mkdirSync(testRecord,{mode:0o700});
289
+ const guardFile=path.join(testRecord,'guard.json'),guardConfig=testGuardConfig(testRecord,root,process.env,testFacts);
290
+ if(general){toolchain=toolchainDirs();
291
+ // The Node on PATH, which the environment identifies by its file.
292
+ Object.assign(guardConfig,{installed:scriptInstalledPaths(root,testFacts.dirs||[]),toolchain,
293
+ node:lookup('node',process.env.PATH,process.cwd())});}
294
+ fs.writeFileSync(guardFile,JSON.stringify(guardConfig),{mode:0o600});
295
+ launchEnv=recordedEnvironment(guardFile);
296
+ // npm's and pnpm's update checks read state outside the checkout and the network.
297
+ if(general)launchEnv.npm_config_update_notifier='false';
298
+ }
299
+ let runnerPid=null;
300
+ const record=[];
200
301
  // A Windows full reconciliation may finish while queued watcher callbacks
201
302
  // are still arriving. Fold callbacks delivered before process launch into
202
303
  // the starting checkpoint; callbacks after launch still disqualify reuse.
@@ -211,17 +312,20 @@ async function execute() {
211
312
  }
212
313
  const snap=before?.snapshots?.[name];
213
314
  if(!snap||!snap.observationHealthy)captureIssues.push('start checkpoint unavailable');
214
- const started=Date.now();
315
+ await testRunBoundary('before-launch');
316
+ const started=Date.now(),executionStarted=performance.now();
215
317
  const invocation=await new Promise(resolve=>{
216
318
  if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
217
319
  let child,spawnError=null;
218
320
  try{runLock.phase('launching');
219
- const launch=windowsLaunch(command);
321
+ const launch=windowsLaunch(launched);
220
322
  child=spawn(launch.file,launch.args,{cwd:plan.cwd,
221
- stdio:'inherit',env:process.env,detached:process.platform==='linux',
222
- ...launch.options});}
323
+ stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
324
+ detached:process.platform==='linux',...launch.options});
325
+ child.stdio[3]?.on('data',chunk=>record.push(chunk));}
223
326
  catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
224
- activeChild=child;runLock.phase('running',child.pid??null);
327
+ activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
328
+ mark('cli.child_started',{childPid:child.pid});
225
329
  if(requestedSignal)forward(requestedSignal);
226
330
  child.on('error',e=>{spawnError=e.code||e.name;});
227
331
  child.on('close',async(exit,signal)=>{
@@ -241,6 +345,8 @@ async function execute() {
241
345
  });
242
346
  });
243
347
  const ended=Date.now();
348
+ timing('cli.compiler_execution',executionStarted,{status:invocation.status,exit:invocation.exitCode});
349
+ await testRunBoundary('after-exit');
244
350
  invocation.startedAt=started;invocation.durationMs=ended-started;
245
351
  invocation.argvHash=plan.commandIdentity.argvHash;
246
352
  invocation.argc=command.length;
@@ -248,28 +354,121 @@ async function execute() {
248
354
  invocation.reporting='normal';
249
355
  const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
250
356
  const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
251
- const environmentAfter=contextHashes()[name],probeAfter=probeHash({cwd:plan.cwd,
252
- probes:selected.probes||[],qualification:selected.qualification});
357
+ const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
358
+ timing('cli.context_after',afterContextStarted);
359
+ let probeAfter=null;
360
+ const recordText=Buffer.concat(record).toString('utf8');
361
+ // Lookups the certificate does not cover, kept with the receipt.
362
+ let runFacts=[];
363
+ if(certificate){
364
+ const issue=validateRunRecord(recordText,certificate,runFacts);
365
+ if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
366
+ probeAfter=certificate.value;
367
+ }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
368
+ let guard=null;
369
+ if(testRecord){
370
+ let coverage=null;if(!general)try{coverage=coverageReadable(root,testFacts);}catch{}
371
+ const programs=general?{runner:{file:command[0],args:command.slice(1),cwd:plan.cwd,searchPath:process.env.PATH,
372
+ env:{preload:true,bashEnv:Boolean(process.env.BASH_ENV)}},
373
+ judge:(child,main)=>childProgram(child,{toolchain,parentMain:main}),trusted:scriptCaches}:null;
374
+ guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
375
+ runner:runnerPid,environment:selected.environment||{},coverage,programs});
376
+ runFacts=guard.runFacts;
377
+ // The pnpm executable reads its configuration natively: kept by content.
378
+ if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
379
+ catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
380
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
381
+ if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
382
+ fs.rmSync(testRecord,{recursive:true,force:true});
383
+ }
384
+ // The daemon reads the recording from owned state; control requests are small.
385
+ const recordFile=recording&&path.join(state,`run-record-${recording}.json`);
386
+ if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
253
387
  const endSnapshotStarted=performance.now();
254
- try{after=await call({action:'snapshot',name,contextHashes:contextHashes()},captureTimeout);}
255
- catch{captureIssues.push('end observation unavailable');}
256
- timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
388
+ if(!unobservedDirectRun){
389
+ try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
390
+ ...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
391
+ ...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
392
+ // The start checkpoint's probe may have raised the deadline.
393
+ Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
394
+ catch{captureIssues.push('end observation unavailable');}
395
+ finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
396
+ timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
397
+ }
257
398
  const end=after?.snapshots?.[name];
258
- if(!end||!end.observationHealthy)captureIssues.push('end checkpoint unavailable');
399
+ // A test run's own outputs: git-ignored files it created without reading
400
+ // what was there (test/tmp). When they are the only observed change, the
401
+ // run is kept with the end checkpoint, which includes them.
402
+ let outputs=null;
403
+ let eventsExplained=false;
404
+ if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
405
+ (snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
406
+ // A directory event is not an input change; a change to its files shows
407
+ // in the end checkpoint's file map, which is compared too. A link is kept.
408
+ const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
409
+ const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
410
+ const touched=[...new Set([...changed,...events])].sort();
411
+ const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
412
+ const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
413
+ if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
414
+ }
415
+ // The end certificate, recorded or probed, must cover what this compile received.
416
+ // With no input event, gap or content change in between, it also holds at the start.
417
+ // A run checked by full probes is compared with it too, for its run facts.
418
+ if(recorded&&!certificate){
419
+ const endCertificate=directRunCertificate(plan,selected,end,root),
420
+ questions=endCertificate||(!recording&&probeQuestions(plan,selected,end,root));
421
+ if(!questions)captureIssues.push('no discovery certificate at the end checkpoint');
422
+ else{
423
+ const issue=validateRunRecord(recordText,questions,runFacts=[]);
424
+ if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
425
+ if(recording)probeBefore=probeAfter=endCertificate.value;
426
+ }
427
+ }
428
+ // The receipt holds only run facts that still answer as they did during the run.
429
+ runFacts.sort();
430
+ if(runFacts.length>10000)captureIssues.push('too many lookups outside the discovery certificate');
431
+ else{const changed=runFactChange(runFacts);
432
+ if(changed)captureIssues.push(`a lookup outside the discovery certificate changed during execution: ${changed}`);}
433
+ if(recording&&snap?.observationGaps!==end?.observationGaps)
434
+ captureIssues.push('observation gap during execution');
435
+ if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
259
436
  if(JSON.stringify(environmentBefore)!==JSON.stringify(environmentAfter))
260
437
  captureIssues.push('declared environment changed during execution');
261
- if(!probeBefore||!probeAfter||probeBefore!==snap?.probeHash||probeAfter!==end?.probeHash)
438
+ if(!unobservedDirectRun&&(!probeBefore||!probeAfter||probeBefore!==(recording?end:snap)?.probeHash||
439
+ probeAfter!==end?.probeHash))
262
440
  captureIssues.push('declared state probe unavailable or changed');
263
441
  if(before?.observation?.generation!==after?.observation?.generation)
264
442
  captureIssues.push('observer generation changed during execution');
265
443
  if(snap?.planGeneration!==end?.planGeneration)
266
444
  captureIssues.push('input plan rebuilt during execution');
267
- if(snap?.inputEventSerial!==end?.inputEventSerial)
445
+ if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained)
268
446
  captureIssues.push('declared input event occurred during execution');
269
- if(!snap||!end||snap.planId!==end.planId||snap.fingerprint!==end.fingerprint||
270
- snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
447
+ // A recorded certificate changes only the probe part of the fingerprint.
448
+ if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
449
+ snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
271
450
  const stable=captureIssues.length===0;
272
- const receipt={result,invocation,target,stable,fingerprintVersion:1,
451
+ // Two runs on the same inputs, environment and plan that disagree make the
452
+ // check nondeterministic until its inputs change.
453
+ let nondeterministic=false;
454
+ const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
455
+ if(testRun&&stable&&result){
456
+ const prior=readReceipts(state).checks[name];
457
+ // A difference explained by inputs REDUE did not observe proves nothing.
458
+ nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
459
+ prior.fingerprint===((recording||outputs?end:snap)?.fingerprint??null)&&
460
+ JSON.stringify(declared(prior.environmentHashes))===JSON.stringify(declared(environmentBefore))&&
461
+ JSON.stringify(prior.runFacts||[])===JSON.stringify([...runFacts].sort())&&
462
+ JSON.stringify(prior.environmentReads||{})===JSON.stringify(guard?.environmentReads||{})&&
463
+ (prior.result!==result||prior.nondeterministic===true));
464
+ }
465
+ // A recorded run's receipt carries its run facts, even none; observers that
466
+ // predate run facts do not reuse version 2.
467
+ // The recipe that made the receipt, which status and the hook name.
468
+ const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
469
+ ...(recorded||testRun?{runFacts}:{}),
470
+ ...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
471
+ environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
273
472
  resolutionLinks:plan.resolutionLinks||[],
274
473
  coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
275
474
  checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
@@ -277,14 +476,18 @@ async function execute() {
277
476
  ...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
278
477
  startInputEventSerial:snap?.inputEventSerial??null,
279
478
  endInputEventSerial:end?.inputEventSerial??null}: {})},
280
- planId:snap?.planId??plan.id,fingerprint:snap?.fingerprint??null,
479
+ planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
281
480
  provider:plan.provider,environmentHashes:environmentBefore,
282
- files:snap?.files??null,observerGeneration:before?.observation?.generation??null,
481
+ ...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
482
+ files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
283
483
  startRevision:snap?.revision??null,endRevision:end?.revision??null};
284
484
  const persistenceStarted=performance.now();
285
485
  commitReceipt(state,name,receipt);
486
+ if(testRun){const reads=environmentReads();reads[name]=receipt.environmentReads;atomicJson(readsFile,{schema:1,checks:reads});}
286
487
  timing('cli.receipt_persistence',persistenceStarted);
287
- try{await call({action:'reload'});}catch{/* next read remains conservative */}
488
+ const reloadStarted=performance.now();
489
+ try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
490
+ timing('cli.receipt_reload',reloadStarted);
288
491
  const summary={check:name,result,target:target.status,
289
492
  invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
290
493
  stable,coverage_qualified:receipt.coverage.qualified};
@@ -375,8 +578,28 @@ async function main(){
375
578
  if(action==='status'){console.log(JSON.stringify(cached()));return;}
376
579
  if(action==='sync'||action==='detail'){
377
580
  const started=performance.now();
581
+ if(profiling&&config.checks.some(check=>check.qualification===directQualification)){
582
+ const proxyShapes=Object.entries(process.env).filter(([key])=>/^npm_config_.*proxy$/i.test(key)).map(([key,value])=>{
583
+ try{const url=new URL(value),portDigest=digest(url.port);url.port='';
584
+ return {key,kind:'url',portDigest,withoutPortDigest:digest(url.href)};}
585
+ catch{return {key,kind:value===''?'empty':'non-url',valueDigest:digest(value)};}
586
+ });
587
+ timing('cli.direct_context',started,{interpretation:directInterpretation,
588
+ observedNpmDigest:observedNpmContext(),proxyShapes});
589
+ }
378
590
  let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
379
- Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));}
591
+ Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadline()));
592
+ // A client upgrade can encounter an older running observer. Its successful
593
+ // control response does not establish that it evaluated the latest receipt.
594
+ if(value.receipt_revision===null){
595
+ value=unavailableCached(state,'latest receipt selection unavailable; inspect receipt state before reuse',config.checks);
596
+ }else if(typeof value.receipt_revision!=='string'){
597
+ value=unavailableCached(state,'observer cannot confirm latest receipt selection; stop and start the observer after upgrading',config.checks);
598
+ process.exitCode=2;
599
+ }else if(value.receipt_revision!==receiptRevision(state)){
600
+ value=unavailableCached(state,'receipt selection changed during synchronization; retry synchronized status',config.checks);
601
+ process.exitCode=2;
602
+ }}
380
603
  catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
381
604
  timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
382
605
  if(action==='sync')console.log(JSON.stringify(value));