@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/index.mjs CHANGED
@@ -5,7 +5,7 @@ import {createHash} from 'node:crypto';
5
5
  import micromatch from './glob.mjs';
6
6
  import {sha} from './plan.mjs';
7
7
  import {resolveLinks} from './installed-inputs.mjs';
8
- import {validInputRelative} from './path-identity.mjs';
8
+ import {validInputRelative,identityFields,racyUntil} from './path-identity.mjs';
9
9
 
10
10
  const tracked = root => [...new Set(execFileSync('git',
11
11
  ['ls-files','--cached','--others','--exclude-standard','-z'],
@@ -35,37 +35,43 @@ const pathPrefixes = rel => {
35
35
  return prefixes;
36
36
  };
37
37
 
38
+ // An installed file is observed by identity, not content: every write changes
39
+ // ctime, so its entry changes without reading the whole installation. The
40
+ // compiler's own reads are content-checked by its probe and certificate. A file
41
+ // changed within the racy window could still be rewritten in the same timestamp
42
+ // tick at the same size; such files are tracked in `racy`, and the daemon waits
43
+ // the window out and records them again before it writes a run checkpoint.
44
+ const identity=st=>identityFields.map(key=>String(st[key])).join(':');
38
45
  function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
39
46
  {bigint:true})) {
40
47
  const file=path.join(root, rel);
41
- let value,references=[],bytes=Number(before.size);
48
+ let value,references=[],bytes=installed?0:Number(before.size),racy=0;
42
49
  if(before.isSymbolicLink()){
43
50
  const resolution=resolveLinks(file,root,[],true);
44
51
  if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
45
52
  const target=fs.statSync(resolution.physical,{bigint:true});
46
53
  if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
47
- value='link:'+JSON.stringify(resolution.links)+':'+target.mode+':'+
48
- (installed&&target.nlink>1?target.nlink+':':'')+
49
- hashRegular(resolution.physical).hash;
54
+ if(installed)racy=racyUntil(target);else bytes=Number(target.size);
55
+ value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
56
+ target.mode+':'+hashRegular(resolution.physical).hash);
50
57
  references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
51
58
  .split(path.sep).join('/')),path.relative(root,resolution.physical)
52
59
  .split(path.sep).join('/')])];
53
- bytes=Number(target.size);
54
60
  const targetAfter=fs.statSync(resolution.physical,{bigint:true});
55
- for(const key of ['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'])
61
+ for(const key of identityFields)
56
62
  if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
57
63
  const resolutionAfter=resolveLinks(file,root,[],true);
58
64
  if(resolutionAfter.physical!==resolution.physical||
59
65
  JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
60
66
  throw Error(`input link changed during inspection: ${rel}`);
61
67
  }
62
- else if(before.isFile()) value='file:'+before.mode+':'+
63
- (installed&&before.nlink>1?before.nlink+':':'')+hashRegular(file).hash;
68
+ else if(before.isFile()&&installed){value='stat:'+identity(before);racy=racyUntil(before);}
69
+ else if(before.isFile()) value='file:'+before.mode+':'+hashRegular(file).hash;
64
70
  else throw Error(`non-file input: ${rel}`);
65
71
  const after=fs.lstatSync(file,{bigint:true});
66
- for(const key of ['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'])
72
+ for(const key of identityFields)
67
73
  if(before[key]!==after[key]) throw Error(`input changed during inspection: ${rel}`);
68
- return {hash:sha(value),references,bytes};
74
+ return {hash:sha(value),references,bytes,racy};
69
75
  }
70
76
  const readBuffer=Buffer.allocUnsafe(65536);
71
77
  function hashRegular(file) {
@@ -81,6 +87,7 @@ export class InputIndex {
81
87
  const matcherStarted=performance.now();
82
88
  this.bundle=bundle; this.root=bundle.root; this.files=new Map(); this.members=new Map();
83
89
  this.symlinkRefs=new Map();this.aliasDependencies=new Map();
90
+ this.racy=new Map();
84
91
  this.trackGit=options.trackGit!==false;
85
92
  this.installedRoots=[...new Set(Object.values(bundle.plans).flatMap(plan=>
86
93
  plan.installedPhysicalRoots||[]))];
@@ -149,6 +156,17 @@ export class InputIndex {
149
156
  }
150
157
  return {files,bytes,durationMs:performance.now()-started};
151
158
  }
159
+ // When the racy window of the named checks' installed members ends (0: none).
160
+ racyUntil(names=null) {
161
+ let until=0;
162
+ for(const [rel,time] of this.racy)
163
+ if(!names||[...this.members.get(rel)||[]].some(name=>names.includes(name)))until=Math.max(until,time);
164
+ return until;
165
+ }
166
+ // Records again every installed file whose racy window has passed.
167
+ settleRacy() {
168
+ for(const [rel,time] of [...this.racy])if(time<=Date.now())this.record(rel,rel+' changed');
169
+ }
152
170
  summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
153
171
  revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
154
172
  record(rel, changeReason=rel+' changed', observedStat=null) {
@@ -182,6 +200,7 @@ export class InputIndex {
182
200
  }
183
201
  }
184
202
  const oldNames=this.members.get(rel)||new Set(), newNames=new Set(fresh===undefined?[]:matched);
203
+ if(inspection?.racy>Date.now()&&newNames.size)this.racy.set(rel,inspection.racy);else this.racy.delete(rel);
185
204
  if(old===fresh && oldNames.size===newNames.size && [...oldNames].every(n=>newNames.has(n))) return false;
186
205
  for(const name of new Set([...oldNames,...newNames])) {
187
206
  const row=this.checks.get(name);
@@ -304,3 +323,14 @@ export class InputIndex {
304
323
  return out;
305
324
  }
306
325
  }
326
+
327
+ // The index state a worker sends and a restart retains; hydrate() in daemon.mjs
328
+ // reads it back.
329
+ export function dehydrateIndex(value) {
330
+ return {files:[...value.files],
331
+ aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
332
+ rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
333
+ sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
334
+ eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
335
+ rehashedBytes:value.rehashedBytes,profile:value.profile,racy:[...value.racy]};
336
+ }
@@ -73,10 +73,18 @@ def main(root):
73
73
  file_paths = {}
74
74
  intentional_removals = set()
75
75
 
76
+ # No input lies in a .git directory (config refuses such patterns and the
77
+ # index skips them), so none is watched: git creates object directories
78
+ # there on most commits, and each would be an observation gap.
79
+ def in_git(target):
80
+ return '.git' in os.path.relpath(target, root).split(os.sep)
81
+
76
82
  def add_tree(folder):
77
83
  stack = [folder]
78
84
  while stack:
79
85
  current = stack.pop()
86
+ if in_git(current):
87
+ continue
80
88
  wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
81
89
  if wd < 0:
82
90
  raise OSError(ctypes.get_errno(), 'inotify_add_watch failed')
@@ -119,6 +127,8 @@ def main(root):
119
127
  emit('gap', reason='inotify_watch_unknown')
120
128
  return False
121
129
  target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
130
+ if in_git(target):
131
+ continue
122
132
  if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
123
133
  emit('gap', reason='inotify_watch_lost')
124
134
  return False
@@ -41,3 +41,11 @@ export function validInputRelative(rel,platform=process.platform) {
41
41
  rel.includes('\\')))return false;
42
42
  return true;
43
43
  }
44
+
45
+ // File identity fields. Any write changes ctime, but timestamps tick in
46
+ // milliseconds: a file changed within the racy window could be rewritten in the
47
+ // same tick at the same size without changing them (git's racy rule).
48
+ export const identityFields=['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'];
49
+ export const racyWindowMs=2000;
50
+ // ctime, which no ordinary tool can set ahead, ends the window.
51
+ export const racyUntil=st=>Number(st.ctimeNs/1000000n)+racyWindowMs;
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {createHash} from 'node:crypto';
5
5
  import {discover} from './plan.mjs';
6
- import {InputIndex} from './index.mjs';
6
+ import {InputIndex,dehydrateIndex} from './index.mjs';
7
7
  import {directProject,directQualification} from './direct-typescript.mjs';
8
8
  import {queriesMatch} from './typescript-list.mjs';
9
9
  import {planGuard,inputKey} from './decision-validation.mjs';
@@ -113,26 +113,14 @@ try {
113
113
  const outside=new InputIndex({root:boundary,plans},{trackGit:false,includeInstalled:true});
114
114
  outside.coldScan();
115
115
  if(outside.unavailable)throw Error(outside.unavailable);
116
- external[boundary]={files:[...outside.files],
117
- aliasDependencies:[...outside.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
118
- rows:[...outside.checks]
119
- .map(([name,row])=>({name,files:[...row.files],sum:row.sum.toString('hex'),
120
- revision:row.revision,lastReason:row.lastReason})),
121
- eventCount:outside.eventCount,rehashedFiles:outside.rehashedFiles,
122
- rehashedBytes:outside.rehashedBytes,profile:outside.profile};
116
+ external[boundary]=dehydrateIndex(outside);
123
117
  }
124
118
  timing('worker.total',totalStarted,{mode:'cold',files:index.files.size});
125
119
  parentPort.postMessage({ok:true,bundle,discoveryMs,indexingMs,guard,planReused,
126
120
  inputKeys:Object.fromEntries([...index.checks].map(([name,row])=>[name,inputKey(row)])),
127
121
  restartMode:'cold-index',
128
122
  external,
129
- index:{files:[...index.files],
130
- aliasDependencies:[...index.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
131
- rows:[...index.checks].map(([name,row])=>({
132
- name,files:[...row.files],sum:row.sum.toString('hex'),revision:row.revision,
133
- lastReason:row.lastReason})),
134
- eventCount:index.eventCount,rehashedFiles:index.rehashedFiles,
135
- rehashedBytes:index.rehashedBytes,profile:index.profile}});
123
+ index:dehydrateIndex(index)});
136
124
  }
137
125
  } catch(error) {
138
126
  parentPort.postMessage({ok:false,error:error?.message||String(error),
@@ -1,6 +1,7 @@
1
1
  // Pinned pnpm 12 node-modules installation provider. The engine sees only
2
2
  // repository-relative input sets and resolution relationships, never pnpm
3
3
  // virtual-store names as semantic identities.
4
+ import {probeTimeLeft} from './probe-deadline.mjs';
4
5
  import {compilerFiles} from './typescript-list.mjs';
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
@@ -96,7 +97,7 @@ export function pnpmWorkspacePackages(root,patterns){
96
97
  const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
97
98
  '-z','--',':(glob)**/package.json'],{cwd:root,
98
99
  env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:32*1024*1024,
99
- timeout:5000}).toString().split('\0').filter(Boolean);
100
+ timeout:probeTimeLeft(5000)}).toString().split('\0').filter(Boolean);
100
101
  for(const file of files){
101
102
  const dir=path.posix.dirname(file);
102
103
  if(dir==='.'||dir.split('/').includes('node_modules')||
@@ -202,7 +203,7 @@ function listedTypeScriptFiles(root,selected,tsc){
202
203
  {cwd:path.join(root,selected.dir),env:process.env,encoding:'utf8',
203
204
  // The native NTFS public workspace takes 3-5 s to list ~770 inputs;
204
205
  // 5 s sometimes expired despite bounded, progressing compiler work.
205
- timeout:10000,maxBuffer:32*1024*1024,stdio:['ignore','pipe','pipe']});
206
+ timeout:probeTimeLeft(10000),maxBuffer:32*1024*1024,stdio:['ignore','pipe','pipe']});
206
207
  if(out.error||out.signal||out.status!==0)
207
208
  throw Error('TypeScript input listing is unavailable');
208
209
  const files=out.stdout.trim().split(/\r?\n/).filter(Boolean)
@@ -0,0 +1,37 @@
1
+ // How long a full probe may run before it is killed and the check stays
2
+ // UNVERIFIED. It grows with the project: the larger of the hint from earlier
3
+ // probes (twice the last successful one, or twice the deadline that last timed
4
+ // out) and the last completed run, between the platform floor and a ceiling.
5
+ import {fileURLToPath} from 'node:url';
6
+ export const probeCeilingMs=process.env.VSTATE_TEST_FAULTS==='1'&&
7
+ Number(process.env.VSTATE_TEST_PROBE_CEILING_MS)||60000;
8
+ // Supported TypeScript probes perform bounded input/toolchain hashing.
9
+ // Native NTFS runs have exceeded the generic 5 s cap under load, despite
10
+ // completing normally and with the duplicated work already removed.
11
+ export const probeFloorMs=qualification=>qualification==='pnpm-tsc-v1'||
12
+ (process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
13
+ .includes(qualification))?10000:5000;
14
+ // REDUE's own probes, which accept --redue-deadline; a user's probe never gets it.
15
+ const builtIn=new Set(['typescript-contract-probe.mjs','direct-typescript-probe.mjs',
16
+ 'yarn-workspace-typecheck-probe.mjs','pnpm-typecheck-probe.mjs'].map(name=>fileURLToPath(new URL(name,import.meta.url))));
17
+ export const builtInProbe=argv=>builtIn.has(argv?.[1]);
18
+ // A deadline read from status.json, as a whole number of milliseconds no
19
+ // longer than a decision could need (0 when absent or unusable).
20
+ export function publishedDeadline(value) {
21
+ return Number.isFinite(value)&&value>0?Math.min(Math.round(value),10*60*1000):0;
22
+ }
23
+ export function probeDeadline({floor,hint=0,runMs=0,ceiling=probeCeilingMs}) {
24
+ const finite=value=>Number.isFinite(value)&&value>0?value:0;
25
+ return Math.round(Math.min(ceiling,Math.max(floor,finite(hint),finite(runMs))));
26
+ }
27
+ // The hint the next probe starts from.
28
+ export function nextProbeHint({durationMs,deadlineMs,timedOut}) {
29
+ return 2*(timedOut?deadlineMs:durationMs);
30
+ }
31
+ // Inside a probe process: what is left of the deadline its caller passed with
32
+ // --redue-deadline=MS, or the fallback when none was passed. An inner call
33
+ // always ends before its parent is killed, so it never outlives the probe.
34
+ export function probeTimeLeft(fallback) {
35
+ const deadline=Number(/^--redue-deadline=(\d+)$/.exec(process.argv.find(arg=>arg.startsWith('--redue-deadline='))||'')?.[1]);
36
+ return deadline?Math.max(1,Math.round(deadline-performance.now()-250)):fallback;
37
+ }
@@ -51,13 +51,26 @@ const realpath=observe('realpath',original.realpathSync,value=>value);
51
51
  realpath.native=observe('realpath',original.native,value=>value);
52
52
  fs.realpathSync=realpath;
53
53
  // readFileSync opens through fs.openSync; only a writable open is a write.
54
+ // The one write a direct run may make is its build-info file (incremental);
55
+ // it is recorded, and the caller checks it is the configured one.
56
+ const buildInfo=file=>typeof file==='string'&&file.endsWith('.tsbuildinfo');
54
57
  fs.openSync=function(file,flags,...rest){
55
- if(!(flags===undefined||flags==='r'||flags==='rs'||flags===fs.constants.O_RDONLY))unsupported??='openSync';
58
+ if(!(flags===undefined||flags==='r'||flags==='rs'||flags===fs.constants.O_RDONLY)){
59
+ if(flags==='w'&&buildInfo(file))note('write',path.resolve(file),null);
60
+ else unsupported??='openSync';
61
+ }
56
62
  return original.openSync.call(this,file,flags,...rest);
57
63
  };
64
+ // tsc creates the build-info directory one level at a time; each is recorded.
65
+ const mkdirSync=fs.mkdirSync;
66
+ fs.mkdirSync=function(file,options,...rest){
67
+ if(typeof file==='string'&&options===undefined)note('mkdir',path.resolve(file),null);
68
+ else unsupported??='mkdirSync';
69
+ return mkdirSync.call(this,file,options,...rest);
70
+ };
58
71
  // Writes, and reads this record cannot answer for, make it unusable. readSync
59
72
  // and fstatSync stay open: readFileSync reads through them.
60
- for(const name of ['writeFileSync','appendFileSync','mkdirSync','unlinkSync','utimesSync',
73
+ for(const name of ['writeFileSync','appendFileSync','unlinkSync','utimesSync',
61
74
  'renameSync','rmSync','watch','watchFile','existsSync','lstatSync','accessSync','opendirSync',
62
75
  'readlinkSync','readFile','stat','lstat','readdir','realpath','access','exists','open','opendir',
63
76
  'readlink','createReadStream']){
@@ -2,18 +2,21 @@
2
2
  import fs from 'node:fs';import path from 'node:path';import {createHash} from 'node:crypto';
3
3
  import {createRequire} from 'node:module';import {spawnSync} from 'node:child_process';
4
4
  import {compilerFiles} from './typescript-list.mjs';
5
+ import {buildInfoFor} from './direct-typescript.mjs';
6
+ import {probeTimeLeft} from './probe-deadline.mjs';
5
7
  import {withinPath as within,realObservedPath} from './path-identity.mjs';
6
8
  import {timing} from './decision-profile.mjs';
7
9
  const require=createRequire(import.meta.url),hash=value=>createHash('sha256').update(value).digest('hex');
8
10
  const digestFile=file=>hash(fs.readFileSync(file));
9
11
  const fail=code=>{throw Object.assign(Error(code),{code});};
10
12
  export function typeScriptInputFacts(root,tsRoot,tsc){
11
- const configReads=new Set(),visited=new Set();
13
+ const configReads=new Set(),visited=new Set(),referenced=[];
14
+ let buildInfo=null;
12
15
  let ts;try{ts=require(tsRoot);}catch{}
13
16
  if(typeof ts?.getParsedCommandLineOfConfigFile==='function'){
14
17
  const host={...ts.sys,readFile(file){configReads.add(path.resolve(file));
15
18
  return ts.sys.readFile(file);},onUnRecoverableConfigFileDiagnostic(){}};
16
- const parseConfig=file=>{
19
+ const parseConfig=(file,top=false)=>{
17
20
  const absolute=path.resolve(file);
18
21
  if(!within(absolute,root))fail('typescript-config-outside-repository');
19
22
  if(visited.has(absolute))return;visited.add(absolute);
@@ -21,13 +24,20 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
21
24
  const parsed=ts.getParsedCommandLineOfConfigFile(absolute,{},host);
22
25
  if(!parsed||parsed.errors?.length)fail('typescript-config-unavailable');
23
26
  if(parsed.options?.plugins?.length)fail('typescript-plugin-unsupported');
24
- if(parsed.options?.incremental||parsed.options?.composite)
25
- fail('typescript-incremental-unsupported');
27
+ // Plain tsc reads a referenced config but never builds it, so only the
28
+ // root config's build settings matter. An incremental root reads and
29
+ // writes one build-info file, which must be inside the checkout.
30
+ if(top){
31
+ if(parsed.options?.composite)fail('typescript-incremental-unsupported');
32
+ const found=buildInfoFor(ts,parsed.options,root);
33
+ if(found.issue)fail(found.issue);
34
+ buildInfo=found.file||null;
35
+ }else referenced.push(parsed);
26
36
  for(const reference of parsed.projectReferences||[])
27
37
  parseConfig(reference.path.endsWith('.json')?reference.path:
28
38
  path.join(reference.path,'tsconfig.json'));
29
39
  };
30
- parseConfig(path.join(root,'tsconfig.json'));
40
+ parseConfig(path.join(root,'tsconfig.json'),true);
31
41
  }else{
32
42
  // TypeScript 7's native compiler does not expose the previous config API.
33
43
  // Its CLI reports the effective config; restrict this path to one direct
@@ -36,7 +46,7 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
36
46
  if(/"(?:extends|references|plugins|incremental|composite)"\s*:/.test(raw))
37
47
  fail('typescript-config-graph-unsupported');
38
48
  const shown=spawnSync(process.execPath,[tsc,'--showConfig'],{cwd:root,
39
- env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
49
+ env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
40
50
  stdio:['ignore','pipe','pipe']});
41
51
  if(shown.error||shown.signal||shown.status!==0)fail('typescript-config-unavailable');
42
52
  const effective=JSON.parse(shown.stdout);
@@ -50,7 +60,7 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
50
60
  const listingStarted=performance.now();
51
61
  const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(tsRoot,root):null;
52
62
  const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'--noEmit','--listFilesOnly'],
53
- {cwd:root,env:process.env,encoding:'utf8',timeout:process.platform==='win32'?10000:4000,
63
+ {cwd:root,env:process.env,encoding:'utf8',timeout:probeTimeLeft(process.platform==='win32'?10000:4000),
54
64
  maxBuffer:16*1024*1024,
55
65
  stdio:['ignore','pipe','pipe']});
56
66
  timing('typescript_contract.list_files',listingStarted,{status:listed.status??null,
@@ -60,9 +70,23 @@ export function typeScriptInputFacts(root,tsRoot,tsc){
60
70
  .map(file=>path.resolve(file));
61
71
  if(!files.length||files.some(file=>!within(realObservedPath(file),root)))
62
72
  fail('typescript-input-outside-repository');
73
+ // A program that uses a referenced project's sources or outputs depends on
74
+ // that project's build, which plain tsc does not run. tsc redirects only
75
+ // TypeScript sources; a shared .d.ts or .json is an ordinary input.
76
+ const declaration=/\.d\.[cm]?ts$|\.json$/;
77
+ const sources=new Set(),outputs=new Set(),outDirs=[];
78
+ for(const parsed of referenced){
79
+ const dirs=[parsed.options.outDir,parsed.options.declarationDir].filter(Boolean).map(dir=>path.resolve(dir));
80
+ outDirs.push(...dirs);
81
+ for(const file of parsed.fileNames.map(name=>path.resolve(name)))if(!declaration.test(file)){
82
+ sources.add(file);if(!dirs.length)outputs.add(file.replace(/\.[cm]?tsx?$/,ext=>ext.replace(/tsx?$/,'d.ts')));}
83
+ }
84
+ if(files.some(file=>sources.has(file)||outputs.has(file)||outDirs.some(dir=>within(file,dir))))
85
+ fail('typescript-project-reference-consumed');
63
86
  // Every hashed configuration must also be a certified question.
64
87
  const queries=captured?.queries&&[...configReads].every(file=>
65
88
  captured.queries.some(([kind,read])=>kind==='readFile'&&read===file))?captured.queries:null;
66
89
  return {configs:[...configReads].sort().map(file=>[file,digestFile(file)]),
67
- files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries};
90
+ files:[...new Set(files)].sort().map(file=>[file,digestFile(file)]),queries,
91
+ ...(buildInfo?{buildInfo}:{})};
68
92
  }
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import {createHash} from 'node:crypto';
7
7
  import {createRequire} from 'node:module';
8
+ import {identityFields,racyUntil} from './path-identity.mjs';
8
9
  const require=createRequire(import.meta.url);
9
10
  const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
10
11
  export function queryAnswer(kind,file,options) {
@@ -38,6 +39,24 @@ export function queryAnswer(kind,file,options) {
38
39
  throw Error('unknown compiler filesystem question');
39
40
  }catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
40
41
  }
42
+ // Null when every run fact answers as it did during the run, otherwise the
43
+ // first path that does not. Asked in-process; nothing is launched.
44
+ export function runFactChange(facts) {
45
+ if(!Array.isArray(facts))return 'an unreadable run fact';
46
+ for(const fact of facts){
47
+ if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile'].includes(fact[0])||
48
+ typeof fact[1]!=='string')return 'an unreadable run fact';
49
+ const [kind,file,answer]=fact;
50
+ let now;try{now=queryAnswer(kind,file);}catch{return file;}
51
+ if(now!==answer)return file;
52
+ }
53
+ return null;
54
+ }
55
+ // Why a receipt's run facts no longer hold, or null when they all do.
56
+ export function runFactReason(facts) {
57
+ const changed=runFactChange(facts);
58
+ return changed&&`a lookup the last run made answers differently now: ${changed}`;
59
+ }
41
60
  export function queriesMatch(queries) {
42
61
  if(!queries?.length)return false;
43
62
  return queries.every(([kind,file,answer,options])=>
@@ -92,13 +111,15 @@ export function compilerFiles(tsRoot,cwd) {
92
111
  ['readDirectory',full,[...result].sort(),options]);
93
112
  return result;
94
113
  };
114
+ const realpath=file=>{remember('realpath',file);return ts.sys.realpath(file);};
115
+ // `extends` of a package config resolves through realpath too.
95
116
  const parsed=ts.getParsedCommandLineOfConfigFile(path.join(cwd,'tsconfig.json'),{},
96
- {...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,
117
+ {...ts.sys,readFile,fileExists,directoryExists,getDirectories,readDirectory,realpath,
97
118
  onUnRecoverableConfigFileDiagnostic(){}});
98
119
  if(!parsed||parsed.errors?.length)throw Error('TypeScript configuration unavailable');
99
120
  const host=ts.createCompilerHost(parsed.options);
100
121
  Object.assign(host,{readFile,fileExists,directoryExists,getDirectories,readDirectory,
101
- realpath(file){remember('realpath',file);return ts.sys.realpath(file);},
122
+ realpath,
102
123
  getSourceFile(file,languageVersion,onError){
103
124
  const text=readFile(file);return text===undefined?undefined:
104
125
  ts.createSourceFile(file,text,languageVersion);
@@ -109,9 +130,9 @@ export function compilerFiles(tsRoot,cwd) {
109
130
  // Stable answers are necessary even at first capture; a concurrent edit
110
131
  // cannot become the basis for a reusable discovery result.
111
132
  const facts=[...queries.values()];
112
- // A certificate must cover every program file; references parse further
113
- // configurations outside this host and are not certified.
114
- const complete=!parsed.projectReferences?.length&&files.every(file=>queries.has('readFile\0'+file));
133
+ // A certificate must cover every program file. Referenced configurations
134
+ // are parsed through this host, so their reads and listings are certified.
135
+ const complete=files.every(file=>queries.has('readFile\0'+file));
115
136
  return {files,queries:complete&&queriesMatch(facts)?facts:null};
116
137
  }
117
138
  // Identity of a read file and, for a link, of its target. Writes through any
@@ -119,8 +140,8 @@ export function compilerFiles(tsRoot,cwd) {
119
140
  // coarse timestamp cannot hide a second write in the same tick (git's racy rule).
120
141
  export function fileIdentity(file,now=Date.now()) {
121
142
  try{const link=fs.lstatSync(file,{bigint:true}),target=link.isSymbolicLink()?fs.statSync(file,{bigint:true}):link;
122
- if(Number(link.ctimeNs/1000000n)>=now-2000||Number(target.ctimeNs/1000000n)>=now-2000)return null;
123
- const fields=st=>['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'].map(key=>String(st[key]));
143
+ if(racyUntil(link)>=now||racyUntil(target)>=now)return null;
144
+ const fields=st=>identityFields.map(key=>String(st[key]));
124
145
  return JSON.stringify([fields(target),target===link?null:[fs.realpathSync(file),...fields(link)]]);}
125
146
  catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
126
147
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Read-only applicability probe. Its output is one digest or a fixed reason;
3
3
  // source, configuration, environment values, and command output are not logged.
4
+ import {probeTimeLeft} from './probe-deadline.mjs';
4
5
  import {compilerFiles} from './typescript-list.mjs';
5
6
  import {contextOnly,probeResult} from './probe-result.mjs';
6
7
  import fs from 'node:fs';
@@ -79,7 +80,7 @@ try{
79
80
  const tsc=yarnLocalTsc(root);
80
81
  const captured=process.argv.includes('--redue-checkpoint')?compilerFiles(path.join(root,'node_modules/typescript'),cwd):null;
81
82
  const listed=captured?{status:0,stdout:captured.files.join('\n')}:spawnSync(process.execPath,[tsc,'-p','.','--listFilesOnly'],
82
- {cwd,env:process.env,encoding:'utf8',timeout:4000,maxBuffer:16*1024*1024,
83
+ {cwd,env:process.env,encoding:'utf8',timeout:probeTimeLeft(4000),maxBuffer:16*1024*1024,
83
84
  stdio:['ignore','pipe','pipe']});
84
85
  if(listed.error||listed.signal||listed.status!==0)fail('typescript-input-list-unavailable');
85
86
  const inputs=listed.stdout.trim().split(/\r?\n/).filter(Boolean)