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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.mjs CHANGED
@@ -8,18 +8,20 @@ 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 {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
22
+ import {statusSummary} from './cached-state.mjs';
23
+ import {directProject,directProbeValue,reviewedTypeScriptVersions} from './direct-typescript.mjs';
24
+ import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
23
25
  import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
24
26
 
25
27
  const configFile=path.resolve(process.argv[2]);
@@ -152,6 +154,14 @@ function reuseCertificate(row,key,allowReuse,forceFull,checked) {
152
154
  metrics.probeReuses=(metrics.probeReuses||0)+1;
153
155
  return null;
154
156
  }
157
+ // The current full-probe deadline for a check; see probe-deadline.mjs. Hints
158
+ // are kept per check name, so a plan rebuild (each Windows decision) keeps them.
159
+ const probeHints=new Map();
160
+ function fullProbeDeadline(row) {
161
+ const run=receipts[row.plan.name]?.invocation;
162
+ return probeDeadline({floor:probeFloorMs(row.plan.qualification),hint:probeHints.get(row.plan.name),
163
+ runMs:run?.status==='exited'?run.durationMs:0});
164
+ }
155
165
  function probe(row,allowReuse=false,{launch=true}={}) {
156
166
  const started=performance.now();
157
167
  const supported=new Map([
@@ -204,18 +214,21 @@ function probe(row,allowReuse=false,{launch=true}={}) {
204
214
  row.probeCache=null;
205
215
  }
206
216
  const values=[];let checkpoint=null;
217
+ // The slowest of this check's probes sets the hint for all of them.
218
+ const deadline=fullProbeDeadline(row);let hint=0;
219
+ const learn=()=>{if(hint)probeHints.set(row.plan.name,hint);};
207
220
  for(const argv of row.plan.probes) {
208
- // Supported TypeScript probes perform bounded input/toolchain hashing.
209
- // Native NTFS runs have exceeded the generic 5 s cap under load, despite
210
- // completing normally and with the duplicated work already removed.
211
- const timeout=row.plan.qualification==='pnpm-tsc-v1'||
212
- (process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
213
- .includes(row.plan.qualification))?10000:5000;
221
+ const timeout=deadline,launched=performance.now();
214
222
  mark('daemon.probe_launch',{mode:'full',reason:fullReason,deadlineMs:timeout});
215
- const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[])],{cwd:row.plan.cwd,
223
+ // A built-in probe gets its deadline, so its inner compiler calls share it.
224
+ const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[]),
225
+ ...(builtIn?[`--redue-deadline=${timeout}`]:[])],{cwd:row.plan.cwd,
216
226
  timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:traceEnvironment()});
217
227
  mark('daemon.probe_complete',{mode:'full',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
228
+ const timedOut=out.error?.code==='ETIMEDOUT';
229
+ if(!out.error||timedOut)hint=Math.max(hint,nextProbeHint({durationMs:performance.now()-launched,deadlineMs:timeout,timedOut}));
218
230
  if(out.error||out.signal||out.status!==0) {
231
+ learn();
219
232
  if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
220
233
  row.probeHash=null;row.probeAt=0;
221
234
  const code=['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
@@ -223,7 +236,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
223
236
  /VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
224
237
  const explanations={
225
238
  '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',
239
+ 'direct-typescript-version-unsupported':`direct compiler recipe supports reviewed TypeScript ${reviewedTypeScriptVersions.join(', ')} only`,
227
240
  'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
228
241
  'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
229
242
  'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
@@ -232,7 +245,11 @@ function probe(row,allowReuse=false,{launch=true}={}) {
232
245
  'typescript-executable-changed':'local TypeScript executable changed or is missing',
233
246
  'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
234
247
  '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',
248
+ 'typescript-incremental-unsupported':'TypeScript composite compilation needs explicit review',
249
+ 'typescript-buildinfo-outside-repository':'TypeScript incremental build-info file is outside the observed repository',
250
+ 'typescript-incremental-unsound':'TypeScript assumeChangesOnlyAffectDirectDependencies makes an incremental result depend on build history',
251
+ 'typescript-buildinfo-name-unsupported':'TypeScript build-info file needs a plain name ending in .tsbuildinfo',
252
+ 'typescript-project-reference-consumed':'TypeScript program uses files of a referenced project, which plain tsc does not build',
236
253
  'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
237
254
  'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
238
255
  'typescript-input-list-unavailable':'TypeScript input listing did not complete',
@@ -275,6 +292,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
275
292
  }
276
293
  values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
277
294
  }
295
+ learn();
278
296
  const value=sha(JSON.stringify(values));
279
297
  if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
280
298
  row.revision++;row.lastReason='declared state probe changed or recovered';}
@@ -319,7 +337,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
319
337
  plan_id:row.plan.id,observed_at:lastObservation,
320
338
  invocation:r?.invocation||null,target_provenance:r?.target||null,
321
339
  ...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
322
- coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false};
340
+ coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
341
+ ...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
323
342
  if(receiptStateError){item.reason=receiptStateError;return item;}
324
343
  if(!healthy||planning||recovering||index.unavailable){item.reason=
325
344
  (planning||recovering)?'input observation reconciliation pending':
@@ -330,7 +349,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
330
349
  if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
331
350
  if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
332
351
  r.fingerprint===combinedSummary(name).fingerprint;
333
- if(r&&r.fingerprintVersion!==1){item.reason='receipt uses earlier input-resolution format';return item;}
352
+ if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
353
+ item.reason='receipt uses earlier input-resolution format';return item;}
334
354
  if(row.plan.unresolved.length){
335
355
  const checkpoint=item.declared_inputs_match===false?
336
356
  r.planId!==row.plan.id?'; observed input plan changed':
@@ -340,6 +360,9 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
340
360
  return item;
341
361
  }
342
362
  if(row.probeError){item.reason=row.probeError;return item;}
363
+ // The plan leaves out the build-info file it saw at start; the probe sees the current one.
364
+ if(row.probeCache?.layout&&(row.probeCache.layout.buildInfo||null)!==(row.plan.buildInfo||null)){
365
+ item.reason='the incremental build-info setting changed after the observer started; restart the observer';return item;}
343
366
  // An expired probe or missing execution context cannot confirm CURRENT, but
344
367
  // observed input content that already differs from the receipt is STALE.
345
368
  let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
@@ -396,11 +419,24 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
396
419
  if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
397
420
  item.reason=unconfirmed;item.changed_inputs=[];}
398
421
  } else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
422
+ // Lookups the run made outside the certificate are asked again for every
423
+ // answer, cached or synchronized, before it can say CURRENT.
424
+ // A direct run recorded before run facts may have made lookups no probe
425
+ // asks; only Windows, which never records direct runs, keeps reusing them.
426
+ } else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
427
+ item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
428
+ } else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
399
429
  } else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
400
430
  item.reuse_eligible=result==='PASS';}
401
431
  return item;
402
432
  });
403
433
  }
434
+ // Null when every run fact in a receipt answers as it did during the run.
435
+ function runFactReason(facts) {
436
+ const started=performance.now(),reason=runFactChanged(facts);
437
+ timing('daemon.run_facts',started,{facts:facts.length,changed:reason?1:0});
438
+ return reason;
439
+ }
404
440
  // The indexed inputs already differ from the latest receipt, so no probe result
405
441
  // can make this check CURRENT. A sync answers STALE without a full probe. A run
406
442
  // probes, or builds the certificate from its recording (adopt), and a revert
@@ -439,7 +475,7 @@ function adopt(name,row,token) {
439
475
  return done('inputs or certificate changed during the run');
440
476
  const cache=pending.cache,argv=row.plan.probes[0];
441
477
  let result;
442
- try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}));}
478
+ try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}),buildInfoPath(row.plan));}
443
479
  catch{return done('recording unreadable');}
444
480
  if(result.issue)return done(result.issue);
445
481
  const identities=new Map(cache.identities);
@@ -454,14 +490,11 @@ function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem
454
490
  // Reload notifications accelerate visibility but are not required for it: a
455
491
  // wrapper may persist its outcome even when its control connection is denied.
456
492
  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,
493
+ const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
494
+ // A decision may run every check's probes in turn: callers wait for all of them.
495
+ const probing=[...(index?.checks.values()||[])].filter(row=>row.plan.probes.length);
496
+ const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
497
+ ...(probing.length?{probe_deadline_ms:probing.reduce((sum,row)=>sum+fullProbeDeadline(row)*row.plan.probes.length,0)}:{}),
465
498
  observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
466
499
  generation,observed_at:lastObservation,
467
500
  events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
@@ -496,16 +529,10 @@ function hydrate(bundle,saved) {
496
529
  }
497
530
  next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
498
531
  next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
532
+ next.racy=new Map(saved.racy||[]);
499
533
  return next;
500
534
  }
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
- }
535
+ const dehydrate=dehydrateIndex;
509
536
  function retainIndex() {
510
537
  if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
511
538
  !fs.existsSync(snapshotPath))return;
@@ -958,7 +985,7 @@ async function request(message) {
958
985
  mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
959
986
  timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
960
987
  plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
961
- probe:'reuse_only_with_identical_content_and_context',installed:'current_content_required',
988
+ probe:'reuse_only_with_identical_content_and_context',installed:'current_identity_required',
962
989
  planPhase:metrics.planPhase||'initializing',pending:pending.size,
963
990
  notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
964
991
  if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
@@ -1065,6 +1092,20 @@ async function request(message) {
1065
1092
  }
1066
1093
  timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
1067
1094
  lastObservation=Date.now();publish();
1095
+ // A run checkpoint never holds an installed identity from the racy window:
1096
+ // a same-size write in the same timestamp tick would keep it. Wait the
1097
+ // window out (at most 2 s), record those files again, and mark the
1098
+ // checkpoint unusable if one is still inside it.
1099
+ let racy=false;
1100
+ if(message.action==='snapshot'||message.action==='prelaunch'){
1101
+ const names=message.name?[message.name]:null;
1102
+ const indexes=()=>[index,...[...external.values()].map(e=>e.index)].filter(Boolean);
1103
+ const wait=Math.min(racyWindowMs,Math.max(0,...indexes().map(i=>i.racyUntil(names)))-Date.now());
1104
+ if(wait>0){metrics.racyWaits=(metrics.racyWaits||0)+1;await new Promise(resolve=>setTimeout(resolve,wait+5));}
1105
+ for(const i of indexes())i.settleRacy();
1106
+ racy=indexes().some(i=>i.racyUntil(names)>Date.now());
1107
+ if(racy)metrics.racyCheckpoints=(metrics.racyCheckpoints||0)+1;
1108
+ }
1068
1109
  const applicabilityStarted=performance.now();
1069
1110
  const data=status(message.contextHashes,synchronizedInstalled,
1070
1111
  process.platform==='win32'&&healthy&&!planning&&!planDirty);
@@ -1075,18 +1116,16 @@ async function request(message) {
1075
1116
  planGeneration:row.plan.id,
1076
1117
  inputEventSerial:inputEventSerial.get(name)||0,
1077
1118
  probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
1078
- observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
1119
+ observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
1079
1120
  // Export only a currently guarded, check-specific discovery certificate.
1080
1121
  // The caller compares it with the answers its own compile received. Schema 2
1081
1122
  // certifies the realpath of every directory readDirectory visits. The
1082
1123
  // snapshot stays the start of the observed interval, including validation.
1083
1124
  if(message.runCertificate===1&&continuityBarrier()){
1084
1125
  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'&&
1126
+ // Also after an accepted recording: the caller compares its record with
1127
+ // this certificate and finds its run facts with it.
1128
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&
1090
1129
  !row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
1091
1130
  cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
1092
1131
  cache.output&&cache.queryData&&data.snapshots[message.name])
@@ -1095,6 +1134,15 @@ async function request(message) {
1095
1134
  context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
1096
1135
  queryData:cache.queryData};
1097
1136
  }
1137
+ // For a run checked by full probes: the last probe's questions, only to
1138
+ // find the run's lookups outside them. Never evidence for reuse.
1139
+ if(message.runFacts===1&&data.snapshots[message.name]){
1140
+ const row=index?.checks.get(message.name),cache=row?.probeCache;
1141
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&cache?.queryData&&
1142
+ typeof cache.context==='string'&&cache.input===inputKey(row)&&cache.hash===row.probeHash&&
1143
+ cache.guard===planGuard(index.bundle))
1144
+ data.snapshots[message.name].probeQuestions={context:cache.context,queryData:cache.queryData};
1145
+ }
1098
1146
  const row=index?.checks.get(message.name);
1099
1147
  if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
1100
1148
  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||[];
@@ -10,12 +10,20 @@ import {timing} from './decision-profile.mjs';
10
10
  import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
11
11
  import {withinPath} from './path-identity.mjs';
12
12
  const hash=value=>createHash('sha256').update(value).digest('hex');
13
- export function directRunCertificateAllowed(selected) {
13
+ // Whether a direct run is recorded at all: on macOS and Linux, always, so its
14
+ // run facts are known even when full probes check it.
15
+ export function directRunRecordable(selected) {
14
16
  return ['darwin','linux'].includes(process.platform)&&selected.qualification==='npm-typescript-direct-v1'&&
15
17
  selected.probes?.length===1&&
16
- selected.probes[0][1]===fileURLToPath(new URL('direct-typescript-probe.mjs',import.meta.url))&&
18
+ selected.probes[0][1]===fileURLToPath(new URL('direct-typescript-probe.mjs',import.meta.url));
19
+ }
20
+ // Whether a recorded run may replace the probes (tests can force full probes).
21
+ export function directRunCertificateAllowed(selected) {
22
+ return directRunRecordable(selected)&&
17
23
  !(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_RUN_PROBES==='1');
18
24
  }
25
+ // The incremental build-info file the plan leaves out of the inputs, if any.
26
+ export const buildInfoPath=plan=>typeof plan?.buildInfo==='string'?path.join(plan.root,plan.buildInfo):null;
19
27
  // The start snapshot's discovery certificate, checked without a child process.
20
28
  // Returns what a recorded run is compared against, or null for full probes.
21
29
  export function directRunCertificate(plan,selected,snapshot,root) {
@@ -26,7 +34,7 @@ export function directRunCertificate(plan,selected,snapshot,root) {
26
34
  c.planId!==snapshot.planId||c.cwd!==plan.cwd||
27
35
  c.probeDefinition!==hash(JSON.stringify(selected.probes))||
28
36
  ![c.output,c.context,c.stderrHash].every(s=>typeof s==='string'&&/^[a-f0-9]{64}$/.test(s))||
29
- typeof c.queryData!=='string'||c.queryData.length>512*1024)
37
+ typeof c.queryData!=='string'||c.queryData.length>1024*1024)
30
38
  return fallback('certificate_unavailable');
31
39
  // The compile runs in this process's environment; the daemon's is never substituted.
32
40
  if(directEnvironmentIssue())return fallback('environment_unsupported');
@@ -41,9 +49,21 @@ export function directRunCertificate(plan,selected,snapshot,root) {
41
49
  const queries=JSON.parse(inflateSync(Buffer.from(c.queryData,'base64'),{maxOutputLength:8*1024*1024}));
42
50
  if(!Array.isArray(queries)||!queries.length)return fallback('certificate_invalid');
43
51
  timing('cli.run_certificate',started,{outcome:'available',queries:queries.length});
44
- return {value,queries,context:c.context,compiler};
52
+ return {value,queries,context:c.context,compiler,buildInfo:buildInfoPath(plan)};
45
53
  }catch{return fallback('certificate_invalid');}
46
54
  }
55
+ // The last probe's questions, sent for a run checked by full probes. They only
56
+ // show which of the run's lookups are run facts; the full probes, not these,
57
+ // establish the result.
58
+ export function probeQuestions(plan,selected,snapshot,root) {
59
+ const q=snapshot?.probeQuestions;
60
+ if(!directRunRecordable(selected)||typeof q?.queryData!=='string'||
61
+ q.queryData.length>1024*1024||typeof q.context!=='string')return null;
62
+ try{const queries=JSON.parse(inflateSync(Buffer.from(q.queryData,'base64'),{maxOutputLength:8*1024*1024}));
63
+ if(!Array.isArray(queries)||!queries.length)return null;
64
+ return {queries,context:q.context,compiler:directCompiler(root,{verify:false}),buildInfo:buildInfoPath(plan)};}
65
+ catch{return null;}
66
+ }
47
67
  // Without a start certificate, the daemon may offer to build one from this run's
48
68
  // recording at the end checkpoint. Returns its token, or null for full probes.
49
69
  export function directRunRecording(plan,selected,snapshot,root) {
@@ -93,6 +113,18 @@ function statAgrees([kind,file,answer,options],target,result) {
93
113
  }
94
114
  const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
95
115
  file!==compiler.entry&&file.toLowerCase()===compiler.entry.toLowerCase();
116
+ // The certificate question that asks again what a run's stat, realpath or
117
+ // package.json read answered, in queryAnswer's terms; null when it cannot be asked again.
118
+ function runFact(kind,file,answer) {
119
+ if(kind==='stat')return answer===null||answer==='f'?['fileExists',file,answer&&true]:
120
+ answer==='d'?['directoryExists',file,true]:null;
121
+ if(kind==='realpath')return answer===null||typeof answer==='string'?['realpath',file,answer]:null;
122
+ // Declaration checks read the package.json of each dependency they resolve.
123
+ // Its content hash is asked again like any other run fact; other reads are not.
124
+ if(kind==='read'&&path.basename(file)==='package.json')
125
+ return answer===null||typeof answer==='string'?['readFile',file,answer]:null;
126
+ return null;
127
+ }
96
128
  // Null when every answer the run received agrees with the certified state,
97
129
  // otherwise why not. The compiler is deterministic given its context and its
98
130
  // answers, so a run whose answers all agree computed the certified result.
@@ -100,17 +132,36 @@ const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
100
132
  // compiler tree are covered by the daemon's toolchain identity instead. tsc
101
133
  // also stats a case-swapped copy of its own path to detect case sensitivity;
102
134
  // only that one stat is exempt.
103
- export function runRecordIssue(record,certificate) {
135
+ // With a `facts` array, a stat or realpath the probe never asked about is
136
+ // added to it instead (declaration diagnostics resolve module paths that
137
+ // building the program does not), as is a read of a package.json. The receipt
138
+ // keeps those run facts, and status asks them again before every CURRENT
139
+ // answer. Any other uncovered read, and any directory listing, is an issue.
140
+ // The record's facts without the build-info file an incremental run reads and
141
+ // writes, or {issue} when the run wrote anything else. That file is compiler
142
+ // state, not an input. A directory tsc created for it was missing when tsc
143
+ // checked it, so that check goes too; every other check stays.
144
+ export function withoutBuildInfo(facts,buildInfo) {
145
+ const created=new Set(facts.filter(([kind])=>kind==='mkdir').map(([,file])=>file));
146
+ for(const [kind,file] of facts){
147
+ if(kind==='write'&&file!==buildInfo)return {issue:`the compiler wrote ${file}`};
148
+ if(kind==='mkdir'&&!buildInfo?.startsWith(file+path.sep))return {issue:`the compiler created ${file}`};
149
+ }
150
+ return {facts:facts.filter(([kind,file])=>kind!=='write'&&kind!=='mkdir'&&file!==buildInfo&&
151
+ !(kind==='stat'&&created.has(file)))};
152
+ }
153
+ export function runRecordIssue(record,certificate,runFacts=null) {
104
154
  if(record?.schema!==1||!Array.isArray(record.facts))return 'record unavailable';
105
155
  if(record.unsupported)return `unsupported ${record.unsupported}`;
106
156
  if(record.conflict)return `answer changed during the run: ${record.conflict}`;
107
- const {compiler,queries}=certificate;
157
+ const {compiler,queries}=certificate,kept=withoutBuildInfo(record.facts,certificate.buildInfo||null);
158
+ if(kept.issue)return kept.issue;
108
159
  if(record.main!==compiler.entry||!Array.isArray(record.node)||
109
160
  directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
110
161
  const inCompiler=withinCompiler(compiler);
111
162
  const facts=new Map();
112
163
  for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
113
- for(const [kind,file,answer] of record.facts){
164
+ for(const [kind,file,answer] of kept.facts){
114
165
  if(inCompiler(file))continue;
115
166
  const known=facts.get(file)||[];
116
167
  let checks=[];
@@ -123,40 +174,54 @@ export function runRecordIssue(record,certificate) {
123
174
  else if(kind==='stat')checks=[...known,...(facts.get(path.dirname(file))||[])]
124
175
  .map(q=>statAgrees(q,file,answer)).filter(value=>value!==null);
125
176
  else return `unknown answer ${kind}`;
126
- if(!checks.some(value=>value!==undefined))return `${kind} ${file} is not in the discovery certificate`;
177
+ if(!checks.some(value=>value!==undefined)){
178
+ const fact=runFacts&&runFact(kind,file,answer);
179
+ if(fact){runFacts.push(fact);continue;}
180
+ return `${kind} ${file} is not in the discovery certificate`;
181
+ }
127
182
  if(checks.includes(false))return `${kind} ${file} differs from the discovery certificate`;
183
+ // Covered only by a listing of its directory: a later probe may keep the
184
+ // same program while that listing changes, so the run's own lookup stays
185
+ // a run fact.
186
+ if(kind==='stat'&&runFacts&&!known.length){const fact=runFact(kind,file,answer);if(fact)runFacts.push(fact);}
128
187
  }
129
188
  return null;
130
189
  }
131
- // Compares the record run-recorder.cjs wrote to fd 3.
132
- export function validateRunRecord(text,certificate) {
190
+ // Compares the record run-recorder.cjs wrote to fd 3. Run facts go to `facts`.
191
+ export function validateRunRecord(text,certificate,facts=[]) {
133
192
  const started=performance.now();
134
193
  let record=null;
135
194
  try{record=JSON.parse(text);}catch{}
136
195
  let issue;
137
- try{issue=runRecordIssue(record,certificate);}catch{issue='record unreadable';}
196
+ try{issue=runRecordIssue(record,certificate,facts);}catch{issue='record unreadable';}
138
197
  timing('cli.run_recording',started,{outcome:issue?'rejected':'validated',
139
- facts:record?.facts?.length||0,...(issue?{reason:issue}:{})});
198
+ facts:record?.facts?.length||0,runFacts:facts.length,...(issue?{reason:issue}:{})});
140
199
  return issue;
141
200
  }
142
201
  // A certificate for the current inputs, built from a recorded run and the
143
202
  // certificate of the last full probe, or {issue}. The run must read the same
144
203
  // files, with the same configuration, and every answer it received must be
145
- // covered by the old questions; only file contents may differ. The program
204
+ // covered by the old questions or be a run fact; only file contents may differ.
205
+ // Run facts stay out of this certificate: the receipt keeps them, and status
206
+ // asks them again for every answer. The program
146
207
  // layout is then the same, so the output is rebuilt as the probe computes it.
147
208
  // Limit: a file whose role changes (a package.json newly imported as a module)
148
209
  // keeps its old role, so a later full probe can report the check STALE.
149
- export function recordedCertificate(text,cache,compiler) {
210
+ export function recordedCertificate(text,cache,compiler,buildInfo=null) {
150
211
  let record=null;
151
212
  try{record=JSON.parse(text);}catch{}
152
213
  if(record?.schema!==1||!Array.isArray(record.facts))return {issue:'record unavailable'};
153
214
  if(!cache?.layout||!cache.queries?.length)return {issue:'no previous layout'};
215
+ const kept=withoutBuildInfo(record.facts,buildInfo);
216
+ if(kept.issue)return {issue:kept.issue};
154
217
  const inCompiler=withinCompiler(compiler),reads=new Map();
155
- for(const [kind,file,answer] of record.facts)if(kind==='read'&&!inCompiler(file)){
218
+ const before=new Map(cache.queries.filter(q=>q[0]==='readFile'&&!inCompiler(q[1])).map(q=>[q[1],q[2]]));
219
+ for(const [kind,file,answer] of kept.facts)if(kind==='read'&&!inCompiler(file)){
156
220
  if(answer!==null&&typeof answer!=='string')return {issue:`read ${file} failed`};
221
+ // A package.json only declaration checks read is a run fact, not a program read.
222
+ if(!before.has(file)&&path.basename(file)==='package.json')continue;
157
223
  reads.set(file,answer);
158
224
  }
159
- const before=new Map(cache.queries.filter(q=>q[0]==='readFile'&&!inCompiler(q[1])).map(q=>[q[1],q[2]]));
160
225
  if(reads.size!==before.size||[...before.keys()].some(file=>!reads.has(file)))
161
226
  return {issue:'the run read different files'};
162
227
  if(cache.layout.configs.some(([file])=>!reads.has(file)||reads.get(file)!==before.get(file)))
@@ -169,7 +234,9 @@ export function recordedCertificate(text,cache,compiler) {
169
234
  return {issue:'the run loaded different lib files'};
170
235
  const changed=new Set([...reads].filter(([file,answer])=>before.get(file)!==answer).map(([file])=>file));
171
236
  const queries=cache.queries.map(q=>q[0]==='readFile'&&changed.has(q[1])?[q[0],q[1],reads.get(q[1]),...q.slice(3)]:q);
172
- const issue=runRecordIssue(record,{compiler,queries,context:cache.context});
237
+ // Run facts belong to the receipt, which the caller builds; they do not
238
+ // change the program layout.
239
+ const issue=runRecordIssue({...record,facts:kept.facts},{compiler,queries,context:cache.context},[]);
173
240
  if(issue)return {issue};
174
241
  const files=[];
175
242
  for(const [file,digest] of cache.layout.files){
@@ -15,8 +15,8 @@ try{
15
15
  entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
16
16
  const contextHash=directContextHash(compiler,context.node);
17
17
  if(!contextOnly(contextHash)){
18
- const {queries,configs,files}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
19
- const layout={root,script,configs,files,context};
18
+ const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
19
+ const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
20
20
  probeResult(directProbeOutput(layout),contextHash,queries,layout);
21
21
  }
22
22
  }catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:'typescript-contract-unavailable'}`);process.exitCode=2;}
@@ -10,7 +10,10 @@ const reviewed={
10
10
  '5.5.2':{entry:'lib/tsc.js',digest:'953b4844b5f6edc745be844b6c367c227b59029b73c4182f508f90cc47e21816'},
11
11
  '5.6.3':{entry:'lib/tsc.js',digest:'e674b5dc4dac50f6e91be30094aeea983a1f340231717e62b0b6199ae3049eb2'},
12
12
  '5.9.3':{entry:'lib/_tsc.js',digest:'d1b32e75475710fdc85102b1365254d84ceb192d3639d962c53fe1355ce274ca'},
13
+ '6.0.2':{entry:'lib/_tsc.js',digest:'273e16620fe23362e085980d6dd822e1315d81ba792221c9b6008755fdafccdd'},
14
+ '6.0.3':{entry:'lib/_tsc.js',digest:'e0a6519f0d0a90125b7fd3bdbbcf700e167656beb3baf8aca8ed8bc037494537'},
13
15
  };
16
+ export const reviewedTypeScriptVersions=Object.keys(reviewed);
14
17
  const fail=code=>{throw Object.assign(Error(code),{code});};
15
18
  const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
16
19
  export const directEnvironment={variables:['NODE_ENV','NODE_OPTIONS','NODE_PATH','NODE_COMPILE_CACHE','VSCODE_INSPECTOR_OPTIONS'],
@@ -78,9 +81,35 @@ export function directCompiler(root,{verify=true}={}){
78
81
  // The direct probe's output: root, script, configuration and program files with
79
82
  // their byte digests, and the execution context. A recorded run rebuilds it from
80
83
  // the same layout (direct-run-certificate.mjs).
81
- export function directProbeOutput({root,script,configs,files,context}){
84
+ export function directProbeOutput({root,script,configs,files,context,buildInfo}){
82
85
  const named=list=>list.map(([file,digest])=>[hash(file),digest]);
83
- return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context}));
86
+ return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context,
87
+ ...(buildInfo?{buildInfo}:{})}));
88
+ }
89
+ // The build-info file an incremental root config makes tsc read and write,
90
+ // as {file} relative to the root, {} when not incremental, or {issue}. The
91
+ // probe and the plan both use this rule. It must be inside the checkout, end
92
+ // in .tsbuildinfo (the only write the run recorder allows) and contain no glob
93
+ // characters (the plan excludes it with a pattern). An option that makes the
94
+ // result depend on the build-info history is refused.
95
+ export function buildInfoFor(ts,options,root) {
96
+ if(!options?.incremental)return {};
97
+ if(options.assumeChangesOnlyAffectDirectDependencies)return {issue:'typescript-incremental-unsound'};
98
+ const file=ts.getTsBuildInfoEmitOutputFilePath?.(options);
99
+ if(!file||!withinPath(path.resolve(file),root))return {issue:'typescript-buildinfo-outside-repository'};
100
+ const relative=path.relative(root,path.resolve(file)).split(path.sep).join('/');
101
+ if(!relative.endsWith('.tsbuildinfo')||/[*?[\]{}()!+@,]/.test(relative))return {issue:'typescript-buildinfo-name-unsupported'};
102
+ return {file:relative};
103
+ }
104
+ // The plan's build-info file, or null. The compiler is loaded only when the
105
+ // root config sets incremental or extends another config.
106
+ export function directBuildInfo(root,script) {
107
+ const config=path.join(root,'tsconfig.json');
108
+ let raw;try{raw=fs.readFileSync(config,'utf8');}catch{return null;}
109
+ if(!/"(?:incremental|extends)"\s*:/.test(raw))return null;
110
+ const compiler=directProject(root,script,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
111
+ const parsed=ts.getParsedCommandLineOfConfigFile(config,{},{...ts.sys,onUnRecoverableConfigFileDiagnostic(){}});
112
+ return parsed?.options?.composite?null:buildInfoFor(ts,parsed?.options,root).file??null;
84
113
  }
85
114
  // The probe value the daemon stores for one direct probe's checkpoint output.
86
115
  export function directProbeValue(argv,output,stderrHash){