@redue/cli 0.1.0-alpha.11 → 0.1.0-alpha.12

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.
@@ -6,68 +6,84 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import {spawn} from 'node:child_process';
9
+ import {projectState} from './project-location.mjs';
10
+ import {realObservedPath} from './path-identity.mjs';
9
11
 
10
12
  export const hookCommand='redue agent hook claude';
11
13
  const safe=/^[A-Za-z0-9_./:=,+-]+$/;
12
14
  const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
13
15
 
16
+ export const defaultConfig='redue.config.json';
17
+ // The `redue` prefix for a config beside the project root; null when the name
18
+ // would need shell quoting.
19
+ export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
20
+ safe.test(configFile)?`redue --config ${configFile}`:null;
14
21
  // Exact commands that rerun each configured typecheck. Names that would need
15
22
  // shell quoting get no form, so the hook never fires for them.
16
- export function hookForms(config){
17
- const rows=[];
23
+ export function hookForms(config,configFile=defaultConfig){
24
+ const rows=[],redue=redueFor(configFile);
18
25
  for(const check of config?.checks||[]){
19
26
  if(!typecheck(check)||typeof check.name!=='string'||!safe.test(check.name))continue;
20
- const forms=[`redue run ${check.name}`];
27
+ const forms=redue?[`${redue} run ${check.name}`]:[];
21
28
  // Other forms run from the project root, so they match only a root check.
22
- if(check.cwd!==undefined&&check.cwd!=='.'){rows.push({check:check.name,forms});continue;}
29
+ if(check.cwd!==undefined&&check.cwd!=='.'){if(forms.length)rows.push({check:check.name,forms});continue;}
23
30
  if(config.packageManager==='npm'&&typeof check.script==='string'&&safe.test(check.script))
24
31
  forms.push(`npm run ${check.script}`);
25
32
  if(check.qualification==='npm-typescript-direct-v1')forms.push('npx tsc --noEmit');
26
33
  if(Array.isArray(check.command)&&check.command.length&&
27
34
  check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
28
35
  forms.push(check.command.join(' '));
29
- rows.push({check:check.name,forms:[...new Set(forms)]});
36
+ if(forms.length)rows.push({check:check.name,forms:[...new Set(forms)]});
30
37
  }
31
38
  return rows;
32
39
  }
33
40
 
34
41
  // Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
35
- // or `-N`. Newlines separate commands, so they never match.
42
+ // or `-N`, then optionally `;` or `&&` and the rest of the command. A form has
43
+ // no shell metacharacters, so the first separator after it ends it. Newlines
44
+ // separate commands, so they never match. The rest keeps its own spacing.
36
45
  function split(command){
37
46
  if(typeof command!=='string'||/[\r\n]/.test(command))return null;
38
- const [,base,suffix]=command.trim().replace(/[ \t]+/g,' ')
39
- .match(/^(.*?)((?: 2>&1)?(?: \| (?:tail|head) (?:-n \d+|-\d+))?)$/);
40
- return {base,suffix};
47
+ const [,base,suffix,rest=null]=command.trim()
48
+ .match(/^(.*?)((?:[ \t]+2>&1)?(?:[ \t]+\|[ \t]+(?:tail|head)[ \t]+(?:-n[ \t]+\d+|-\d+))?)(?:[ \t]*(?:;|&&)[ \t]*([^\s;&|].*))?$/);
49
+ const one=text=>text.replace(/[ \t]+/g,' ');
50
+ return {base:one(base),suffix:one(suffix),rest};
41
51
  }
42
- export function matchCheck(command,config){
43
- const parts=split(command);
44
- return parts&&hookForms(config).find(row=>row.forms.includes(parts.base))?.check||null;
52
+ function matched(command,config,configFile){
53
+ const parts=split(command),check=parts&&hookForms(config,configFile).find(row=>row.forms.includes(parts.base))?.check;
54
+ return check?{check,rest:parts.rest}:null;
45
55
  }
46
- // The same check through REDUE, keeping the output suffix; null if it already is.
47
- export function recordedCommand(command,name,configFile='redue.config.json'){
48
- const parts=split(command);
49
- if(!parts||!safe.test(name)||!safe.test(configFile))return null;
50
- const run=`redue ${configFile==='redue.config.json'?'':`--config ${configFile} `}run ${name}`;
51
- return parts.base===run||parts.base===`redue run ${name}`?null:run+parts.suffix;
56
+ export function matchCheck(command,config,configFile){return matched(command,config,configFile)?.check||null;}
57
+ // The same check through REDUE, keeping the output suffix; null if it already
58
+ // is, or if more commands follow it.
59
+ export function recordedCommand(command,name,configFile=defaultConfig){
60
+ const parts=split(command),redue=redueFor(configFile);
61
+ if(!parts||parts.rest!==null||!safe.test(name)||!redue)return null;
62
+ const run=`${redue} run ${name}`;
63
+ return parts.base===run?null:run+parts.suffix;
52
64
  }
53
65
 
54
- export function verdict(status,name){
66
+ export function verdict(status,name,rest=null){
55
67
  const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
56
68
  status.checks.find(item=>item?.name===name):null;
57
69
  const runId=row?.invocation?.runId;
58
70
  if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
59
71
  typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
60
72
  return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}); `+
61
- 'rerun skipped. To run it anyway, prefix the command with REDUE_FORCE=1.';
73
+ 'rerun skipped. This counts as a pass for the current files; no status check is needed. '+
74
+ 'To run it anyway, prefix the command with REDUE_FORCE=1.'+
75
+ (rest===null?'':` Nothing else in the command ran either; run the rest on its own: ${rest}`);
62
76
  }
63
77
 
64
78
  function synchronizedStatus(entry,config,root,timeout){
65
79
  return new Promise(resolve=>{
66
80
  let child,out='',done=false;
67
- const finish=value=>{if(done)return;done=true;clearTimeout(timer);
81
+ const finish=value=>{if(done)return;done=true;clearTimeout(timer);process.off('SIGTERM',stop);
68
82
  if(child?.pid&&child.exitCode===null)try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}
69
83
  resolve(value);};
70
84
  const timer=setTimeout(()=>finish(null),timeout);
85
+ // Claude Code ends a hook that outlives its own timeout; end the status call with it.
86
+ const stop=()=>finish(null);process.once('SIGTERM',stop);
71
87
  // Its own process group on POSIX, so a timeout also ends the engine it starts.
72
88
  try{child=spawn(process.execPath,[entry,'--config',config,'status','--sync','--json'],
73
89
  {cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
@@ -79,8 +95,22 @@ function synchronizedStatus(entry,config,root,timeout){
79
95
  });
80
96
  }
81
97
 
98
+ // Waiting for status never costs more than half the check: half the last
99
+ // completed run's duration, at most 10 s. With no completed run, 2 s.
100
+ export function statusTimeout(status,name){
101
+ const run=Array.isArray(status?.checks)?status.checks.find(row=>row?.name===name)?.invocation:null;
102
+ return run?.status==='exited'&&Number.isFinite(run.durationMs)&&run.durationMs>=0?
103
+ Math.min(run.durationMs/2,10000):2000;
104
+ }
105
+ // From the observer's last published status, which is small, not the receipts.
106
+ export function lastRunTimeout(config,parsed,name){
107
+ try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
108
+ return statusTimeout(JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8')),name);}
109
+ catch{return 2000;}
110
+ }
111
+
82
112
  // Returns the hook's stdout object, or null to allow the command.
83
- export async function claudeHook(input,{entry,timeout=2000}){
113
+ export async function claudeHook(input,{entry,timeout}){
84
114
  if(input?.tool_name!=='Bash')return null;
85
115
  const command=input.tool_input?.command,root=input.cwd;
86
116
  if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
@@ -89,13 +119,16 @@ export async function claudeHook(input,{entry,timeout=2000}){
89
119
  if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
90
120
  !/^[^/\\]+\.json$/.test(record.config))return null;
91
121
  const config=path.join(root,record.config);
92
- const name=matchCheck(command,JSON.parse(fs.readFileSync(config,'utf8')));
93
- if(!name)return null;
94
- const status=await synchronizedStatus(entry,config,root,timeout),reason=verdict(status,name);
122
+ const parsed=JSON.parse(fs.readFileSync(config,'utf8')),match=matched(command,parsed,record.config);
123
+ if(!match)return null;
124
+ const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
125
+ reason=verdict(status,name,match.rest);
95
126
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
96
127
  permissionDecision:'deny',permissionDecisionReason:reason}};
97
128
  // Rewrite only on a healthy answer for this check: without an observer,
98
- // `redue run` cannot record, so the command stays as Claude sent it.
129
+ // `redue run` cannot record, so the command stays as Claude sent it. A chain
130
+ // stays too: the rewrite returns "allow", which would also skip the prompt
131
+ // for the rest of the chain.
99
132
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
100
133
  status.checks.some(item=>item?.name===name)?recordedCommand(command,name,record.config):null;
101
134
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
package/src/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
4
- import {directRunCertificateAllowed,directRunCertificate,directRunRecording,validateRunRecord} from './direct-run-certificate.mjs';
4
+ import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
5
+ import {runFactChange} from './typescript-list.mjs';
5
6
  import path from 'node:path';
6
7
  import net from 'node:net';
7
8
  import os from 'node:os';
@@ -245,7 +246,10 @@ async function execute() {
245
246
  probeBefore=certificate?.value??(recording?null:probeHash(probePlan));
246
247
  }
247
248
  // The record arrives on fd 3, so the compiler's environment is unchanged.
248
- const launched=certificate||recording?[command[0],'--require',path.join(here,'run-recorder.cjs'),
249
+ // Every direct run is recorded: even one checked by full probes needs its
250
+ // lookups outside the certificate, which no probe makes.
251
+ const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
252
+ const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
249
253
  ...command.slice(1)]:command;
250
254
  const record=[];
251
255
  // A Windows full reconciliation may finish while queued watcher callbacks
@@ -270,7 +274,7 @@ async function execute() {
270
274
  try{runLock.phase('launching');
271
275
  const launch=windowsLaunch(launched);
272
276
  child=spawn(launch.file,launch.args,{cwd:plan.cwd,
273
- stdio:certificate||recording?['inherit','inherit','inherit','pipe']:'inherit',env:process.env,
277
+ stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:process.env,
274
278
  detached:process.platform==='linux',...launch.options});
275
279
  child.stdio[3]?.on('data',chunk=>record.push(chunk));}
276
280
  catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
@@ -308,8 +312,10 @@ async function execute() {
308
312
  timing('cli.context_after',afterContextStarted);
309
313
  let probeAfter=null;
310
314
  const recordText=Buffer.concat(record).toString('utf8');
315
+ // Lookups the certificate does not cover, kept with the receipt.
316
+ let runFacts=[];
311
317
  if(certificate){
312
- const issue=validateRunRecord(recordText,certificate);
318
+ const issue=validateRunRecord(recordText,certificate,runFacts);
313
319
  if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
314
320
  probeAfter=certificate.value;
315
321
  }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
@@ -319,7 +325,7 @@ async function execute() {
319
325
  const endSnapshotStarted=performance.now();
320
326
  if(!unobservedDirectRun){
321
327
  try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
322
- ...(recording?{runCertificate:1,adopt:recording}:{})},captureTimeout);}
328
+ ...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},captureTimeout);}
323
329
  catch{captureIssues.push('end observation unavailable');}
324
330
  finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
325
331
  timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
@@ -327,16 +333,22 @@ async function execute() {
327
333
  const end=after?.snapshots?.[name];
328
334
  // The end certificate, recorded or probed, must cover what this compile received.
329
335
  // With no input event, gap or content change in between, it also holds at the start.
330
- if(recording&&end?.recordingAccepted===true)probeBefore=probeAfter=end.probeHash;
331
- else if(recording){
332
- const endCertificate=directRunCertificate(plan,selected,end,root);
333
- if(!endCertificate)captureIssues.push('no discovery certificate at the end checkpoint');
336
+ // A run checked by full probes is compared with it too, for its run facts.
337
+ if(recorded&&!certificate){
338
+ const endCertificate=directRunCertificate(plan,selected,end,root),
339
+ questions=endCertificate||(!recording&&probeQuestions(plan,selected,end,root));
340
+ if(!questions)captureIssues.push('no discovery certificate at the end checkpoint');
334
341
  else{
335
- const issue=validateRunRecord(recordText,endCertificate);
342
+ const issue=validateRunRecord(recordText,questions,runFacts=[]);
336
343
  if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
337
- probeBefore=probeAfter=endCertificate.value;
344
+ if(recording)probeBefore=probeAfter=endCertificate.value;
338
345
  }
339
346
  }
347
+ // The receipt holds only run facts that still answer as they did during the run.
348
+ runFacts.sort();
349
+ if(runFacts.length>10000)captureIssues.push('too many lookups outside the discovery certificate');
350
+ else{const changed=runFactChange(runFacts);
351
+ if(changed)captureIssues.push(`a lookup outside the discovery certificate changed during execution: ${changed}`);}
340
352
  if(recording&&snap?.observationGaps!==end?.observationGaps)
341
353
  captureIssues.push('observation gap during execution');
342
354
  if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
@@ -355,7 +367,10 @@ async function execute() {
355
367
  if(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
356
368
  snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
357
369
  const stable=captureIssues.length===0;
358
- const receipt={result,invocation,target,stable,fingerprintVersion:1,
370
+ // A recorded run's receipt carries its run facts, even none; observers that
371
+ // predate run facts do not reuse version 2.
372
+ const receipt={result,invocation,target,stable,fingerprintVersion:recorded?2:1,
373
+ ...(recorded?{runFacts}:{}),
359
374
  resolutionLinks:plan.resolutionLinks||[],
360
375
  coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
361
376
  checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
package/src/daemon.mjs CHANGED
@@ -8,18 +8,19 @@ import {Worker} from 'node:worker_threads';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {subscribe,hasHistory,backend} from './platform-observation.mjs';
10
10
  import {controlEndpoint} from './control-endpoint.mjs';
11
- import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
11
+ import {observedRelative,withinPath,realObservedPath,racyWindowMs} from './path-identity.mjs';
12
12
  import micromatch from './glob.mjs';
13
13
  import {sha} from './plan.mjs';
14
- import {InputIndex} from './index.mjs';
14
+ import {InputIndex,dehydrateIndex} from './index.mjs';
15
15
  import {permittedRoots} from './installed-inputs.mjs';
16
16
  import {assertOwnedStatePlacement} from './owned-state.mjs';
17
17
  import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
18
18
  import {history,closeHistory} from './observation.mjs';
19
19
  import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
20
- import {queriesHold} from './typescript-list.mjs';
21
- import {directProject,directProbeValue} from './direct-typescript.mjs';
22
- import {recordedCertificate} from './direct-run-certificate.mjs';
20
+ import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
21
+ import {statusSummary} from './cached-state.mjs';
22
+ import {directProject,directProbeValue,reviewedTypeScriptVersions} from './direct-typescript.mjs';
23
+ import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
23
24
  import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
24
25
 
25
26
  const configFile=path.resolve(process.argv[2]);
@@ -223,7 +224,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
223
224
  /VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
224
225
  const explanations={
225
226
  'direct-typescript-implementation-unreviewed':'installed TypeScript bytes or membership differ from the reviewed compiler',
226
- 'direct-typescript-version-unsupported':'direct compiler recipe supports reviewed TypeScript 5.5.2, 5.6.3 and 5.9.3 only',
227
+ 'direct-typescript-version-unsupported':`direct compiler recipe supports reviewed TypeScript ${reviewedTypeScriptVersions.join(', ')} only`,
227
228
  'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
228
229
  'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
229
230
  'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
@@ -232,7 +233,11 @@ function probe(row,allowReuse=false,{launch=true}={}) {
232
233
  'typescript-executable-changed':'local TypeScript executable changed or is missing',
233
234
  'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
234
235
  'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
235
- 'typescript-incremental-unsupported':'TypeScript incremental or composite compilation needs explicit review',
236
+ 'typescript-incremental-unsupported':'TypeScript composite compilation needs explicit review',
237
+ 'typescript-buildinfo-outside-repository':'TypeScript incremental build-info file is outside the observed repository',
238
+ 'typescript-incremental-unsound':'TypeScript assumeChangesOnlyAffectDirectDependencies makes an incremental result depend on build history',
239
+ 'typescript-buildinfo-name-unsupported':'TypeScript build-info file needs a plain name ending in .tsbuildinfo',
240
+ 'typescript-project-reference-consumed':'TypeScript program uses files of a referenced project, which plain tsc does not build',
236
241
  'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
237
242
  'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
238
243
  'typescript-input-list-unavailable':'TypeScript input listing did not complete',
@@ -319,7 +324,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
319
324
  plan_id:row.plan.id,observed_at:lastObservation,
320
325
  invocation:r?.invocation||null,target_provenance:r?.target||null,
321
326
  ...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
322
- coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false};
327
+ coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
328
+ ...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
323
329
  if(receiptStateError){item.reason=receiptStateError;return item;}
324
330
  if(!healthy||planning||recovering||index.unavailable){item.reason=
325
331
  (planning||recovering)?'input observation reconciliation pending':
@@ -330,7 +336,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
330
336
  if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
331
337
  if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
332
338
  r.fingerprint===combinedSummary(name).fingerprint;
333
- if(r&&r.fingerprintVersion!==1){item.reason='receipt uses earlier input-resolution format';return item;}
339
+ if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
340
+ item.reason='receipt uses earlier input-resolution format';return item;}
334
341
  if(row.plan.unresolved.length){
335
342
  const checkpoint=item.declared_inputs_match===false?
336
343
  r.planId!==row.plan.id?'; observed input plan changed':
@@ -340,6 +347,9 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
340
347
  return item;
341
348
  }
342
349
  if(row.probeError){item.reason=row.probeError;return item;}
350
+ // The plan leaves out the build-info file it saw at start; the probe sees the current one.
351
+ if(row.probeCache?.layout&&(row.probeCache.layout.buildInfo||null)!==(row.plan.buildInfo||null)){
352
+ item.reason='the incremental build-info setting changed after the observer started; restart the observer';return item;}
343
353
  // An expired probe or missing execution context cannot confirm CURRENT, but
344
354
  // observed input content that already differs from the receipt is STALE.
345
355
  let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
@@ -396,11 +406,24 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
396
406
  if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
397
407
  item.reason=unconfirmed;item.changed_inputs=[];}
398
408
  } else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
409
+ // Lookups the run made outside the certificate are asked again for every
410
+ // answer, cached or synchronized, before it can say CURRENT.
411
+ // A direct run recorded before run facts may have made lookups no probe
412
+ // asks; only Windows, which never records direct runs, keeps reusing them.
413
+ } else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
414
+ item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
415
+ } else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
399
416
  } else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
400
417
  item.reuse_eligible=result==='PASS';}
401
418
  return item;
402
419
  });
403
420
  }
421
+ // Null when every run fact in a receipt answers as it did during the run.
422
+ function runFactReason(facts) {
423
+ const started=performance.now(),reason=runFactChanged(facts);
424
+ timing('daemon.run_facts',started,{facts:facts.length,changed:reason?1:0});
425
+ return reason;
426
+ }
404
427
  // The indexed inputs already differ from the latest receipt, so no probe result
405
428
  // can make this check CURRENT. A sync answers STALE without a full probe. A run
406
429
  // probes, or builds the certificate from its recording (adopt), and a revert
@@ -439,7 +462,7 @@ function adopt(name,row,token) {
439
462
  return done('inputs or certificate changed during the run');
440
463
  const cache=pending.cache,argv=row.plan.probes[0];
441
464
  let result;
442
- try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}));}
465
+ try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}),buildInfoPath(row.plan));}
443
466
  catch{return done('recording unreadable');}
444
467
  if(result.issue)return done(result.issue);
445
468
  const identities=new Map(cache.identities);
@@ -454,14 +477,8 @@ function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem
454
477
  // Reload notifications accelerate visibility but are not required for it: a
455
478
  // wrapper may persist its outcome even when its control connection is denied.
456
479
  refreshReceipts();
457
- const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
458
- failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
459
- observedFailed=checks.filter(r=>r.result==='FAIL').length,
460
- stale=checks.filter(r=>r.freshness==='STALE').length,
461
- unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
462
- const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
463
- const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
464
- stale,unverified,checks,
480
+ const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
481
+ const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
465
482
  observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
466
483
  generation,observed_at:lastObservation,
467
484
  events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
@@ -496,16 +513,10 @@ function hydrate(bundle,saved) {
496
513
  }
497
514
  next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
498
515
  next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
516
+ next.racy=new Map(saved.racy||[]);
499
517
  return next;
500
518
  }
501
- function dehydrate(value) {
502
- return {files:[...value.files],
503
- aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
504
- rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
505
- sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
506
- eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
507
- rehashedBytes:value.rehashedBytes,profile:value.profile};
508
- }
519
+ const dehydrate=dehydrateIndex;
509
520
  function retainIndex() {
510
521
  if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
511
522
  !fs.existsSync(snapshotPath))return;
@@ -958,7 +969,7 @@ async function request(message) {
958
969
  mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
959
970
  timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
960
971
  plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
961
- probe:'reuse_only_with_identical_content_and_context',installed:'current_content_required',
972
+ probe:'reuse_only_with_identical_content_and_context',installed:'current_identity_required',
962
973
  planPhase:metrics.planPhase||'initializing',pending:pending.size,
963
974
  notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
964
975
  if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
@@ -1065,6 +1076,20 @@ async function request(message) {
1065
1076
  }
1066
1077
  timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
1067
1078
  lastObservation=Date.now();publish();
1079
+ // A run checkpoint never holds an installed identity from the racy window:
1080
+ // a same-size write in the same timestamp tick would keep it. Wait the
1081
+ // window out (at most 2 s), record those files again, and mark the
1082
+ // checkpoint unusable if one is still inside it.
1083
+ let racy=false;
1084
+ if(message.action==='snapshot'||message.action==='prelaunch'){
1085
+ const names=message.name?[message.name]:null;
1086
+ const indexes=()=>[index,...[...external.values()].map(e=>e.index)].filter(Boolean);
1087
+ const wait=Math.min(racyWindowMs,Math.max(0,...indexes().map(i=>i.racyUntil(names)))-Date.now());
1088
+ if(wait>0){metrics.racyWaits=(metrics.racyWaits||0)+1;await new Promise(resolve=>setTimeout(resolve,wait+5));}
1089
+ for(const i of indexes())i.settleRacy();
1090
+ racy=indexes().some(i=>i.racyUntil(names)>Date.now());
1091
+ if(racy)metrics.racyCheckpoints=(metrics.racyCheckpoints||0)+1;
1092
+ }
1068
1093
  const applicabilityStarted=performance.now();
1069
1094
  const data=status(message.contextHashes,synchronizedInstalled,
1070
1095
  process.platform==='win32'&&healthy&&!planning&&!planDirty);
@@ -1075,18 +1100,16 @@ async function request(message) {
1075
1100
  planGeneration:row.plan.id,
1076
1101
  inputEventSerial:inputEventSerial.get(name)||0,
1077
1102
  probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
1078
- observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
1103
+ observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
1079
1104
  // Export only a currently guarded, check-specific discovery certificate.
1080
1105
  // The caller compares it with the answers its own compile received. Schema 2
1081
1106
  // certifies the realpath of every directory readDirectory visits. The
1082
1107
  // snapshot stays the start of the observed interval, including validation.
1083
1108
  if(message.runCertificate===1&&continuityBarrier()){
1084
1109
  const row=index?.checks.get(message.name),cache=row?.probeCache;
1085
- // The daemon checked the recording against the certificate it then validated.
1086
- if(recorded===null&&cache?.recorded===message.adopt&&cache.hash===row.probeHash&&
1087
- cache.input===inputKey(row)&&data.snapshots[message.name])
1088
- data.snapshots[message.name].recordingAccepted=true;
1089
- else if(row?.plan.qualification==='npm-typescript-direct-v1'&&
1110
+ // Also after an accepted recording: the caller compares its record with
1111
+ // this certificate and finds its run facts with it.
1112
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&
1090
1113
  !row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
1091
1114
  cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
1092
1115
  cache.output&&cache.queryData&&data.snapshots[message.name])
@@ -1095,6 +1118,15 @@ async function request(message) {
1095
1118
  context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
1096
1119
  queryData:cache.queryData};
1097
1120
  }
1121
+ // For a run checked by full probes: the last probe's questions, only to
1122
+ // find the run's lookups outside them. Never evidence for reuse.
1123
+ if(message.runFacts===1&&data.snapshots[message.name]){
1124
+ const row=index?.checks.get(message.name),cache=row?.probeCache;
1125
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&cache?.queryData&&
1126
+ typeof cache.context==='string'&&cache.input===inputKey(row)&&cache.hash===row.probeHash&&
1127
+ cache.guard===planGuard(index.bundle))
1128
+ data.snapshots[message.name].probeQuestions={context:cache.context,queryData:cache.queryData};
1129
+ }
1098
1130
  const row=index?.checks.get(message.name);
1099
1131
  if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
1100
1132
  data.snapshots[message.name].recordedCertificate=row.adoption.token;
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import {createHash} from 'node:crypto';
6
6
  import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
7
7
  import {qualifyTypeScript} from './typescript-qualification.mjs';
8
+ import {directBuildInfo} from './direct-typescript.mjs';
8
9
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
9
10
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
10
11
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
@@ -16,6 +17,8 @@ const safePattern=value=>{
16
17
  const raw=value.startsWith('!')?value.slice(1):value;
17
18
  if(!raw||path.isAbsolute(raw)||raw.split(/[\\/]/).includes('..'))
18
19
  throw Error(`unsafe repository-relative input pattern: ${value}`);
20
+ // The observer never watches .git directories; excluding one is fine.
21
+ if(raw===value&&raw.split(/[\\/]/).includes('.git'))throw Error(`input pattern names a .git directory, which is not observed: ${value}`);
19
22
  return value;
20
23
  };
21
24
 
@@ -65,14 +68,22 @@ export function discoverDeclared(config) {
65
68
  catch(e){workspaceIssue=e.message;}
66
69
  }
67
70
  timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
71
+ // An incremental direct check writes its build-info file during each run.
72
+ // It is compiler state, not an input, so the plan leaves it out. Read when
73
+ // the plan is built: a later change to it needs an observer restart, and
74
+ // until then the run reports the unexpected write and is not reusable.
75
+ let buildInfo=null;
76
+ if(selected.qualification==='npm-typescript-direct-v1')
77
+ try{buildInfo=directBuildInfo(root,selected.script);}catch{}
78
+ const excluded=buildInfo?['!'+buildInfo]:[];
68
79
  const installedStarted=performance.now();
69
80
  const installed=resolveDeclaredInstalled(root,
70
- [...(selected.installedInputs||[]),...(workspace?.installed||[])],allowedRoots);
81
+ [...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
71
82
  timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
72
83
  const automatic=qualifyTypeScript(root,selected,
73
84
  {workspace,issue:workspaceIssue});
74
85
  const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
75
- ...(workspace?.generated||[])])].sort();
86
+ ...(workspace?.generated||[]),...excluded])].sort();
76
87
  const additivePatterns=[...new Set([...(selected.generatedInputs||[]),
77
88
  ...installed.internalPatterns])].sort();
78
89
  const unresolved=[...installed.unresolved,...(automatic?.issues||[]),
@@ -113,6 +124,7 @@ export function discoverDeclared(config) {
113
124
  installedMappings:installed.mappings,effectiveEnvironmentGlobs:{},
114
125
  probes:selected.probes||[],environment:selected.environment||{},
115
126
  qualification:selected.qualification||null,
127
+ ...(buildInfo?{buildInfo}:{}),
116
128
  executionProvenance:'declared-command',
117
129
  unresolved:[...new Set(unresolved)].sort()};
118
130
  plan.resolutionCandidates=workspace?.resolutionCandidates||[];