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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +491 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +132 -50
  4. package/docs/README.md +2 -1
  5. package/docs/user/agents.md +299 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +209 -0
  8. package/docs/user/getting-started.md +23 -2
  9. package/docs/user/json.md +2 -0
  10. package/docs/user/node-script.md +154 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/receipt-selection-notice.md +60 -0
  14. package/docs/user/supported-projects.md +56 -23
  15. package/docs/user/tests-direct.md +132 -0
  16. package/package.json +4 -3
  17. package/src/agent-integration.mjs +388 -0
  18. package/src/agent-policy.md +28 -0
  19. package/src/cached-state.mjs +46 -8
  20. package/src/claude-hook.mjs +449 -0
  21. package/src/cli.mjs +263 -40
  22. package/src/daemon.mjs +433 -94
  23. package/src/decision-profile.mjs +15 -1
  24. package/src/decision-validation.mjs +10 -0
  25. package/src/declared-plan.mjs +29 -6
  26. package/src/direct-run-certificate.mjs +252 -0
  27. package/src/direct-typescript-probe.mjs +24 -0
  28. package/src/direct-typescript.mjs +185 -0
  29. package/src/glob.mjs +8 -0
  30. package/src/history-worker.mjs +21 -13
  31. package/src/index.mjs +72 -19
  32. package/src/installed-inputs.mjs +7 -1
  33. package/src/linux-inotify.py +39 -10
  34. package/src/node-onboarding.mjs +172 -53
  35. package/src/node-script.mjs +364 -0
  36. package/src/npm-integrity.mjs +124 -0
  37. package/src/npm-typecheck-environment.mjs +36 -0
  38. package/src/npmrc.mjs +16 -0
  39. package/src/observation.mjs +34 -12
  40. package/src/path-identity.mjs +8 -0
  41. package/src/plan-worker.mjs +17 -17
  42. package/src/pnpm-inputs.mjs +114 -54
  43. package/src/pnpm-shim-templates.json +180 -0
  44. package/src/pnpm-shims.mjs +54 -0
  45. package/src/pnpm-typecheck-probe.mjs +11 -6
  46. package/src/presentation.mjs +9 -1
  47. package/src/probe-deadline.mjs +37 -0
  48. package/src/probe-result.mjs +7 -3
  49. package/src/project-location.mjs +34 -0
  50. package/src/run-recorder.cjs +91 -0
  51. package/src/state-store.mjs +37 -1
  52. package/src/test-direct.mjs +560 -0
  53. package/src/test-recorder.cjs +406 -0
  54. package/src/typescript-contract-probe.mjs +4 -187
  55. package/src/typescript-input-facts.mjs +92 -0
  56. package/src/typescript-invocation.mjs +92 -0
  57. package/src/typescript-list.mjs +108 -13
  58. package/src/typescript-qualification.mjs +29 -49
  59. package/src/workspace-manifests.mjs +24 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
@@ -1,15 +1,29 @@
1
1
  // Opt-in, bounded local diagnostics. Never include paths, command arguments,
2
2
  // environment values, source, or probe output in a timing record.
3
3
  import fs from 'node:fs';
4
+ import {AsyncLocalStorage} from 'node:async_hooks';
5
+ import {randomUUID} from 'node:crypto';
4
6
 
5
7
  const file=process.env.REDUE_DECISION_PROFILE_FILE;
6
8
  export const profiling=process.env.REDUE_DECISION_PROFILE==='1'&&!!file;
7
9
 
10
+ const traces=new AsyncLocalStorage();
11
+ const safe=value=>typeof value==='string'&&/^[a-zA-Z0-9_-]{1,64}$/.test(value)?value:undefined;
12
+ const clean=value=>Object.fromEntries(['request','generation','work'].map(key=>[key,safe(value?.[key])]).filter(([,v])=>v));
13
+ let base={};
14
+ try{base=clean(JSON.parse(process.env.REDUE_PROFILE_CONTEXT||'{}'));}catch{}
15
+ export const traceContext=()=>profiling?{...base,...traces.getStore()}:{};
16
+ export function traceBase(value){if(profiling)base=clean(value);}
17
+ export const traceId=()=>randomUUID();
18
+ export const traceScope=(value,fn)=>profiling?traces.run({...traceContext(),...clean(value)},fn):fn();
19
+ export const traceEnvironment=()=>profiling?{...process.env,REDUE_PROFILE_CONTEXT:JSON.stringify(traceContext())}:process.env;
20
+ export function mark(phase,fields={}){timing(phase,performance.now(),fields);}
21
+
8
22
  export function timing(phase,started,counts={}) {
9
23
  if(!profiling)return;
10
24
  const memory=process.memoryUsage();
11
25
  try{fs.appendFileSync(file,'REDUE_TIMING '+JSON.stringify({
12
- at:Date.now(),pid:process.pid,phase,
26
+ at:Date.now(),monoNs:process.hrtime.bigint().toString(),pid:process.pid,phase,...traceContext(),
13
27
  ms:Math.round((performance.now()-started)*10)/10,
14
28
  rssMiB:Math.round(memory.rss/1048576*10)/10,
15
29
  ...counts
@@ -39,3 +39,13 @@ export function planGuard(bundle) {
39
39
  const rootStat=fs.statSync(root,{bigint:true});
40
40
  return sha(JSON.stringify([String(rootStat.dev),String(rootStat.ino),values]));
41
41
  }
42
+ // Identity of the reviewed compiler tree and the probe's Node executable. A full
43
+ // probe verifies their bytes after this is taken, so any later write changes it.
44
+ export function toolchainKey(tsRoot,executable) {
45
+ const values=[];
46
+ const visit=file=>{const st=fs.lstatSync(file,{bigint:true});
47
+ values.push([file,...['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'].map(key=>String(st[key]))]);
48
+ if(st.isDirectory())for(const name of fs.readdirSync(file).sort())visit(path.join(file,name));};
49
+ visit(tsRoot);visit(fs.realpathSync(executable));
50
+ return sha(JSON.stringify(values));
51
+ }
@@ -5,8 +5,12 @@ 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';
9
+ import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
+ import {qualifyNodeScript,scriptQualification} from './node-script.mjs';
8
11
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
9
12
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
13
+ import {checkRun} from './typescript-invocation.mjs';
10
14
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
11
15
  import {timing} from './decision-profile.mjs';
12
16
 
@@ -16,6 +20,8 @@ const safePattern=value=>{
16
20
  const raw=value.startsWith('!')?value.slice(1):value;
17
21
  if(!raw||path.isAbsolute(raw)||raw.split(/[\\/]/).includes('..'))
18
22
  throw Error(`unsafe repository-relative input pattern: ${value}`);
23
+ // The observer never watches .git directories; excluding one is fine.
24
+ if(raw===value&&raw.split(/[\\/]/).includes('.git'))throw Error(`input pattern names a .git directory, which is not observed: ${value}`);
19
25
  return value;
20
26
  };
21
27
 
@@ -60,19 +66,27 @@ export function discoverDeclared(config) {
60
66
  catch(e){workspaceIssue=e.message;}
61
67
  }
62
68
  if(selected.qualification==='pnpm-tsc-v1'){
63
- try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',
64
- selected.script||selected.command.at(-1));}
69
+ try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',checkRun(selected));}
65
70
  catch(e){workspaceIssue=e.message;}
66
71
  }
67
72
  timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
73
+ // An incremental direct check writes its build-info file during each run.
74
+ // It is compiler state, not an input, so the plan leaves it out. Read when
75
+ // the plan is built: a later change to it needs an observer restart, and
76
+ // until then the run reports the unexpected write and is not reusable.
77
+ let buildInfo=null;
78
+ if(selected.qualification==='npm-typescript-direct-v1')
79
+ try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
80
+ const excluded=buildInfo?['!'+buildInfo]:[];
68
81
  const installedStarted=performance.now();
69
82
  const installed=resolveDeclaredInstalled(root,
70
- [...(selected.installedInputs||[]),...(workspace?.installed||[])],allowedRoots);
83
+ [...(selected.installedInputs||[]),...(workspace?.installed||[]),...excluded],allowedRoots);
71
84
  timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
72
- const automatic=qualifyTypeScript(root,selected,
73
- {workspace,issue:workspaceIssue});
85
+ const automatic=selected.qualification===testsQualification?qualifyTests(root,selected):
86
+ selected.qualification===scriptQualification?qualifyNodeScript(root,selected):
87
+ qualifyTypeScript(root,selected,{workspace,issue:workspaceIssue});
74
88
  const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
75
- ...(workspace?.generated||[])])].sort();
89
+ ...(workspace?.generated||[]),...excluded])].sort();
76
90
  const additivePatterns=[...new Set([...(selected.generatedInputs||[]),
77
91
  ...installed.internalPatterns])].sort();
78
92
  const unresolved=[...installed.unresolved,...(automatic?.issues||[]),
@@ -80,6 +94,14 @@ export function discoverDeclared(config) {
80
94
  ...(workspaceIssue?[workspaceIssue]:[])];
81
95
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
82
96
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
97
+ // A workspace test check, and a general check in a workspace, is qualified
98
+ // against the current package list: a package manifest appearing or pnpm's
99
+ // package list changing re-plans.
100
+ if(selected.qualification===testsQualification&&selected.workspace||selected.qualification===scriptQualification)try{
101
+ for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
102
+ workspacePatterns.add(item);
103
+ if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
104
+ }catch{}
83
105
  for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
84
106
  if(!automatic)for(const category of ['source','generated','installedDependencies',
85
107
  'environment','toolchain','runtime'])if(!selected.coverage?.[category])
@@ -113,6 +135,7 @@ export function discoverDeclared(config) {
113
135
  installedMappings:installed.mappings,effectiveEnvironmentGlobs:{},
114
136
  probes:selected.probes||[],environment:selected.environment||{},
115
137
  qualification:selected.qualification||null,
138
+ ...(buildInfo?{buildInfo}:{}),
116
139
  executionProvenance:'declared-command',
117
140
  unresolved:[...new Set(unresolved)].sort()};
118
141
  plan.resolutionCandidates=workspace?.resolutionCandidates||[];
@@ -0,0 +1,252 @@
1
+ // A caller validates discovery, never an execution outcome. No persistent cache.
2
+ // The direct compiler run records the answers it received (run-recorder.cjs);
3
+ // those answers, not a separate probe, show it consumed the certified inputs.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {createHash} from 'node:crypto';
7
+ import {inflateSync} from 'node:zlib';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {timing} from './decision-profile.mjs';
10
+ import {directCompiler,directContextHash,directEnvironmentIssue,directProbeOutput,directProbeValue} from './direct-typescript.mjs';
11
+ import {withinPath} from './path-identity.mjs';
12
+ const hash=value=>createHash('sha256').update(value).digest('hex');
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) {
16
+ return ['darwin','linux'].includes(process.platform)&&selected.qualification==='npm-typescript-direct-v1'&&
17
+ selected.probes?.length===1&&
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)&&
23
+ !(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_RUN_PROBES==='1');
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;
27
+ // The start snapshot's discovery certificate, checked without a child process.
28
+ // Returns what a recorded run is compared against, or null for full probes.
29
+ export function directRunCertificate(plan,selected,snapshot,root) {
30
+ const started=performance.now(),c=snapshot?.discoveryCertificate;
31
+ const fallback=reason=>{timing('cli.run_certificate',started,{outcome:'fallback',reason});return null;};
32
+ if(!directRunCertificateAllowed(selected))return fallback('unsupported');
33
+ if(!snapshot?.observationHealthy||!c||c.schema!==2||c.planId!==plan.id||
34
+ c.planId!==snapshot.planId||c.cwd!==plan.cwd||
35
+ c.probeDefinition!==hash(JSON.stringify(selected.probes))||
36
+ ![c.output,c.context,c.stderrHash].every(s=>typeof s==='string'&&/^[a-f0-9]{64}$/.test(s))||
37
+ typeof c.queryData!=='string'||c.queryData.length>1024*1024)
38
+ return fallback('certificate_unavailable');
39
+ // The compile runs in this process's environment; the daemon's is never substituted.
40
+ if(directEnvironmentIssue())return fallback('environment_unsupported');
41
+ try{
42
+ const argv=selected.probes[0];
43
+ const value=directProbeValue(argv,c.output,c.stderrHash);
44
+ if(value!==snapshot.probeHash)return fallback('probe_identity_changed');
45
+ // Bytes were verified by the daemon's full probe; its toolchain identity
46
+ // check at the start and end snapshots covers later changes.
47
+ const compiler=directCompiler(root,{verify:false});
48
+ if(selected.command[1]!==compiler.entry)return fallback('command_unsupported');
49
+ const queries=JSON.parse(inflateSync(Buffer.from(c.queryData,'base64'),{maxOutputLength:8*1024*1024}));
50
+ if(!Array.isArray(queries)||!queries.length)return fallback('certificate_invalid');
51
+ timing('cli.run_certificate',started,{outcome:'available',queries:queries.length});
52
+ return {value,queries,context:c.context,compiler,buildInfo:buildInfoPath(plan)};
53
+ }catch{return fallback('certificate_invalid');}
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
+ }
67
+ // Without a start certificate, the daemon may offer to build one from this run's
68
+ // recording at the end checkpoint. Returns its token, or null for full probes.
69
+ export function directRunRecording(plan,selected,snapshot,root) {
70
+ const token=snapshot?.recordedCertificate;
71
+ if(!directRunCertificateAllowed(selected)||!snapshot.observationHealthy||
72
+ typeof token!=='string'||!/^[0-9a-f-]{36}$/.test(token)||snapshot.planId!==plan.id||directEnvironmentIssue())
73
+ return null;
74
+ try{if(selected.command[1]!==directCompiler(root,{verify:false}).entry)return null;}catch{return null;}
75
+ return token;
76
+ }
77
+ // Answers derived from a recorded raw listing, exactly as queryAnswer computes them.
78
+ function derived(kind,entries,options) {
79
+ if(entries===null)return null;
80
+ if(kind==='entries')return entries.map(([name,type])=>[name,type==='d'?'d':type==='l'?'l':'f']).sort();
81
+ const resolved=entries.flatMap(([name,type,target])=>type!=='l'?[[name,type]]:target?[[name,target]]:[]);
82
+ if(kind==='directories'){
83
+ if(entries.some(([,type,target])=>type==='l'&&!target))return null;
84
+ return resolved.filter(([,type])=>type==='d').map(([name])=>name).sort();
85
+ }
86
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
87
+ return resolved.flatMap(([name,type])=>type==='d'?[[name,'d']]:
88
+ type==='f'&&(!suffixes||suffixes.some(value=>name.toLowerCase().endsWith(value)))?[[name,'f']]:[]).sort();
89
+ }
90
+ // What one certificate fact says about a recorded stat answer: true, false, or
91
+ // null when it says nothing. A parent listing that does not name the path only
92
+ // rules some answers out, so it must agree but does not count as cover.
93
+ function statAgrees([kind,file,answer,options],target,result) {
94
+ if(file===target){
95
+ // queryAnswer reports a missing path as null, not false.
96
+ if(answer===null)return ['fileExists','directoryExists','readFile'].includes(kind)?result===null:null;
97
+ if(kind==='fileExists')return result!==null&&(result==='f')===answer;
98
+ if(kind==='directoryExists')return result!==null&&(result==='d')===answer;
99
+ if(kind==='readFile')return result==='f';
100
+ return null;
101
+ }
102
+ if(file!==path.dirname(target)||!Array.isArray(answer))return null;
103
+ const name=path.basename(target);
104
+ if(kind==='directories')return answer.includes(name)?result==='d':result==='d'?false:undefined;
105
+ if(kind==='entries'){const entry=answer.find(row=>row[0]===name);
106
+ return !entry?result===null:entry[1]==='l'?null:entry[1]===result;}
107
+ if(kind==='listing'){const entry=answer.find(row=>row[0]===name);
108
+ if(entry)return entry[1]===result;
109
+ const suffixes=options?.extensions?.map(value=>value.toLowerCase());
110
+ const listed=!suffixes||suffixes.some(value=>name.toLowerCase().endsWith(value));
111
+ return result==='d'||listed&&result==='f'?false:undefined;}
112
+ return null;
113
+ }
114
+ const withinCompiler=compiler=>file=>withinPath(file,compiler.tsRoot)||
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
+ export 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
+ }
128
+ // Null when every answer the run received agrees with the certified state,
129
+ // otherwise why not. The compiler is deterministic given its context and its
130
+ // answers, so a run whose answers all agree computed the certified result.
131
+ // Each answer needs at least one certificate fact about it. Paths inside the
132
+ // compiler tree are covered by the daemon's toolchain identity instead. tsc
133
+ // also stats a case-swapped copy of its own path to detect case sensitivity;
134
+ // only that one stat is exempt.
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) {
154
+ if(record?.schema!==1||!Array.isArray(record.facts))return 'record unavailable';
155
+ if(record.unsupported)return `unsupported ${record.unsupported}`;
156
+ if(record.conflict)return `answer changed during the run: ${record.conflict}`;
157
+ const {compiler,queries}=certificate,kept=withoutBuildInfo(record.facts,certificate.buildInfo||null);
158
+ if(kept.issue)return kept.issue;
159
+ if(record.main!==compiler.entry||!Array.isArray(record.node)||
160
+ directContextHash(compiler,record.node)!==certificate.context)return 'execution context changed';
161
+ const inCompiler=withinCompiler(compiler);
162
+ const facts=new Map();
163
+ for(const query of queries){if(!facts.has(query[1]))facts.set(query[1],[]);facts.get(query[1]).push(query);}
164
+ for(const [kind,file,answer] of kept.facts){
165
+ if(inCompiler(file))continue;
166
+ const known=facts.get(file)||[];
167
+ let checks=[];
168
+ if(kind==='read')checks=known.filter(q=>q[0]==='readFile')
169
+ .map(q=>JSON.stringify(q[2])===JSON.stringify(answer));
170
+ else if(kind==='realpath')checks=known.filter(q=>q[0]==='realpath')
171
+ .map(q=>q[2]===answer);
172
+ else if(kind==='readdir')checks=known.filter(q=>['listing','directories','entries'].includes(q[0]))
173
+ .map(q=>JSON.stringify(derived(q[0],answer,q[3]))===JSON.stringify(q[2]));
174
+ else if(kind==='stat')checks=[...known,...(facts.get(path.dirname(file))||[])]
175
+ .map(q=>statAgrees(q,file,answer)).filter(value=>value!==null);
176
+ else return `unknown answer ${kind}`;
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
+ }
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);}
187
+ }
188
+ return null;
189
+ }
190
+ // Compares the record run-recorder.cjs wrote to fd 3. Run facts go to `facts`.
191
+ export function validateRunRecord(text,certificate,facts=[]) {
192
+ const started=performance.now();
193
+ let record=null;
194
+ try{record=JSON.parse(text);}catch{}
195
+ let issue;
196
+ try{issue=runRecordIssue(record,certificate,facts);}catch{issue='record unreadable';}
197
+ timing('cli.run_recording',started,{outcome:issue?'rejected':'validated',
198
+ facts:record?.facts?.length||0,runFacts:facts.length,...(issue?{reason:issue}:{})});
199
+ return issue;
200
+ }
201
+ // A certificate for the current inputs, built from a recorded run and the
202
+ // certificate of the last full probe, or {issue}. The run must read the same
203
+ // files, with the same configuration, and every answer it received must be
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
207
+ // layout is then the same, so the output is rebuilt as the probe computes it.
208
+ // Limit: a file whose role changes (a package.json newly imported as a module)
209
+ // keeps its old role, so a later full probe can report the check STALE.
210
+ export function recordedCertificate(text,cache,compiler,buildInfo=null) {
211
+ let record=null;
212
+ try{record=JSON.parse(text);}catch{}
213
+ if(record?.schema!==1||!Array.isArray(record.facts))return {issue:'record unavailable'};
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};
217
+ const inCompiler=withinCompiler(compiler),reads=new Map();
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)){
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;
223
+ reads.set(file,answer);
224
+ }
225
+ if(reads.size!==before.size||[...before.keys()].some(file=>!reads.has(file)))
226
+ return {issue:'the run read different files'};
227
+ if(cache.layout.configs.some(([file])=>!reads.has(file)||reads.get(file)!==before.get(file)))
228
+ return {issue:'configuration changed'};
229
+ // Lib files are in the compiler tree: their bytes are covered by its identity,
230
+ // but which of them the program loads must not change.
231
+ const libs=list=>[...new Set(list.filter(file=>inCompiler(file)&&file.endsWith('.d.ts')))].sort();
232
+ if(!Array.isArray(record.compilerReads)||JSON.stringify(libs(record.compilerReads))!==
233
+ JSON.stringify(libs(cache.queries.filter(q=>q[0]==='readFile').map(q=>q[1]))))
234
+ return {issue:'the run loaded different lib files'};
235
+ const changed=new Set([...reads].filter(([file,answer])=>before.get(file)!==answer).map(([file])=>file));
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);
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},[]);
240
+ if(issue)return {issue};
241
+ const files=[];
242
+ for(const [file,digest] of cache.layout.files){
243
+ if(!changed.has(file)){files.push([file,digest]);continue;}
244
+ let bytes;
245
+ try{bytes=fs.readFileSync(file);}catch{return {issue:`${file} unreadable`};}
246
+ // The same answer the compiler received (queryAnswer's readFile).
247
+ if(hash(JSON.stringify(bytes.toString('utf8')))!==reads.get(file))return {issue:`${file} changed after the run`};
248
+ files.push([file,hash(bytes)]);
249
+ }
250
+ const layout={...cache.layout,files};
251
+ return {queries,layout,output:directProbeOutput(layout),changed};
252
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import {directProject,directInterpretation,directContextHash,directProbeOutput} from './direct-typescript.mjs';
3
+ import {typeScriptInputFacts} from './typescript-input-facts.mjs';
4
+ import {contextOnly,probeResult} from './probe-result.mjs';
5
+ import {realObservedPath} from './path-identity.mjs';
6
+ import {timing} from './decision-profile.mjs';
7
+ import {runArgv,runFromArgv} from './typescript-invocation.mjs';
8
+ try{
9
+ const [rootArg,...rest]=process.argv.slice(2).filter(value=>!value.startsWith('--redue-')),root=realObservedPath(rootArg);
10
+ const run=runFromArgv(rest),script=runArgv(run).join(' ');
11
+ // Verify all installed implementation bytes before loading compiler API/code,
12
+ // including the optimized context-only path. A version label is insufficient.
13
+ const implementationStarted=performance.now();
14
+ const compiler=directProject(root,run);
15
+ timing('direct_probe.implementation',implementationStarted,{version:compiler.version});
16
+ const context={interpretation:directInterpretation,compiler:compiler.digest,
17
+ entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
18
+ const contextHash=directContextHash(compiler,context.node);
19
+ if(!contextOnly(contextHash)){
20
+ const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
21
+ const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
22
+ probeResult(directProbeOutput(layout),contextHash,queries,layout);
23
+ }
24
+ }catch(e){console.error(`VSTATE_REASON:${e.code&&/^[a-z-]+$/.test(e.code)?e.code:'typescript-contract-unavailable'}`);process.exitCode=2;}
@@ -0,0 +1,185 @@
1
+ // Versioned, explicit offline compiler recipe. No package manager is executed.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {createHash} from 'node:crypto';
5
+ import {createRequire} from 'node:module';
6
+ import {racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
7
+ import {cachedTarball,lockedPackage,matchTarball,npmCacheDir,registryPackage,tarballFiles} from './npm-integrity.mjs';
8
+ import {runConfig,tscRun} from './typescript-invocation.mjs';
9
+ export const directInterpretation='npm-direct-typescript@1';
10
+ export const directQualification='npm-typescript-direct-v1';
11
+ const reviewed={
12
+ '5.5.2':{entry:'lib/tsc.js',digest:'953b4844b5f6edc745be844b6c367c227b59029b73c4182f508f90cc47e21816'},
13
+ '5.6.3':{entry:'lib/tsc.js',digest:'e674b5dc4dac50f6e91be30094aeea983a1f340231717e62b0b6199ae3049eb2'},
14
+ '5.9.3':{entry:'lib/_tsc.js',digest:'d1b32e75475710fdc85102b1365254d84ceb192d3639d962c53fe1355ce274ca'},
15
+ '6.0.2':{entry:'lib/_tsc.js',digest:'273e16620fe23362e085980d6dd822e1315d81ba792221c9b6008755fdafccdd'},
16
+ '6.0.3':{entry:'lib/_tsc.js',digest:'e0a6519f0d0a90125b7fd3bdbbcf700e167656beb3baf8aca8ed8bc037494537'},
17
+ };
18
+ export const reviewedTypeScriptVersions=Object.keys(reviewed);
19
+ const fail=code=>{throw Object.assign(Error(code),{code});};
20
+ const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
21
+ export const directEnvironment={variables:['NODE_ENV','NODE_OPTIONS','NODE_PATH','NODE_COMPILE_CACHE','VSCODE_INSPECTOR_OPTIONS'],
22
+ prefixes:['NODE_','DYLD_','LD_','TSGO_','TSC_','TS_ETW_'],executableIdentity:true};
23
+ // NODE_OPTIONS flags that only size the V8 heap or pick TLS/DNS behavior; the
24
+ // reviewed compilers open no network connections. Anything else, including any
25
+ // code loader (--require, --import, --loader), is unclassified.
26
+ const inertNodeOptions=[/^--max-(?:old|semi)-space-size=\d+$/,/^--use-(?:openssl|bundled|system)-ca$/,
27
+ /^--dns-result-order=(?:ipv4first|ipv6first|verbatim)$/];
28
+ // One variable's effect on the direct compiler: null when it cannot change what
29
+ // tsc reads or reports. Accepted values stay fingerprinted as NODE_ context.
30
+ export function directVariableIssue(key,value){
31
+ const unsupported='direct-execution-environment-unsupported';
32
+ // The 5.5.2 API can require this unreviewed module during probe loading.
33
+ // Reject even empty/case aliases; do not mutate the caller's environment.
34
+ if(/^TS_ETW_MODULE_PATH$/i.test(key)||/^VSCODE_INSPECTOR_OPTIONS$/i.test(key)&&value)return unsupported;
35
+ if(/^NODE_/i.test(key)){
36
+ // Node 22.13.0 predates env-proxy initialization. This injected flag is
37
+ // inert there, but remains strictly fingerprinted as NODE_ context.
38
+ if(key==='NODE_USE_ENV_PROXY'&&value==='1'&&process.version==='v22.13.0'&&
39
+ !process.allowedNodeEnvironmentFlags.has('--use-env-proxy'))return null;
40
+ if(key==='NODE_ENV'&&['','production','test'].includes(value))return null;
41
+ // PEM data for TLS roots, not code.
42
+ if(key==='NODE_EXTRA_CA_CERTS')return null;
43
+ // Only bare CommonJS requires consult it; the reviewed compilers make one,
44
+ // source-map-support, and only under NODE_ENV=development.
45
+ if(key==='NODE_PATH')return null;
46
+ // No quotes or escapes, so Node splits the value exactly as this does.
47
+ if(key==='NODE_OPTIONS'&&/^[A-Za-z0-9=._ -]*$/.test(value)&&
48
+ value.split(' ').filter(Boolean).every(flag=>inertNodeOptions.some(re=>re.test(flag))))return null;
49
+ if(value!=='')return unsupported;
50
+ }
51
+ if(/^(DYLD_|LD_|TSGO_|TSC_)/i.test(key)&&value!=='')return unsupported;
52
+ return null;
53
+ }
54
+ export function directEnvironmentIssue(env=process.env){
55
+ if(![22,24].includes(Number(process.versions.node.split('.')[0])))return 'direct-node-version-unsupported';
56
+ for(const [key,value] of Object.entries(env)){const issue=directVariableIssue(key,value);if(issue)return issue;}
57
+ return null;
58
+ }
59
+ export function directCompiler(root,{verify=true}={}){
60
+ root=realObservedPath(root);
61
+ const manifest=createRequire(path.join(root,'package.json')).resolve('typescript/package.json');
62
+ const tsRoot=path.dirname(realObservedPath(manifest));
63
+ if(!withinPath(tsRoot,root)||tsRoot!==realObservedPath(path.join(root,'node_modules/typescript')))
64
+ fail('direct-typescript-layout-unsupported');
65
+ const version=JSON.parse(fs.readFileSync(manifest)).version;
66
+ if(!reviewed[version])return lockedCompiler(root,tsRoot,version,verify);
67
+ const spec=reviewed[version];
68
+ if(verify){
69
+ const files=[];
70
+ const walk=(dir,relative='')=>{
71
+ for(const entry of fs.readdirSync(dir,{withFileTypes:true})){
72
+ const name=relative?relative+'/'+entry.name:entry.name,full=path.join(dir,entry.name);
73
+ if(entry.isDirectory())walk(full,name);
74
+ else if(entry.isFile())files.push([name,full]);
75
+ else fail('direct-typescript-implementation-unreviewed');
76
+ }
77
+ };walk(tsRoot);
78
+ const digest=createHash('sha256');
79
+ for(const [name,file] of files.sort(([a],[b])=>a<b?-1:a>b?1:0))digest.update(name+'\0'+hash(fs.readFileSync(file))+'\0');
80
+ if(digest.digest('hex')!==spec.digest)fail('direct-typescript-implementation-unreviewed');
81
+ }
82
+ return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
83
+ }
84
+ // A TypeScript 5.x or 6.0 the lockfile records: the installed package must be
85
+ // the registry's `typescript` at that version, holding exactly the files of
86
+ // the tarball npm cached under the lockfile's integrity. Its identity is that
87
+ // integrity. The entry is lib/_tsc.js where the package has one (5.7 and
88
+ // later), as for the reviewed versions, else lib/tsc.js. A verified tree is
89
+ // remembered per process while its files keep their identity.
90
+ const verifiedTrees=new Map();
91
+ function treeIdentity(tsRoot){
92
+ const parts=[],now=Date.now();
93
+ const walk=folder=>{for(const entry of fs.readdirSync(folder,{withFileTypes:true})){
94
+ const full=path.join(folder,entry.name),st=fs.lstatSync(full,{bigint:true});
95
+ if(racyUntil(st)>now)return null;
96
+ parts.push([full,st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':'));
97
+ if(entry.isDirectory()&&walk(full)===null)return null;
98
+ }};
99
+ try{if(walk(tsRoot)===null)return null;}catch{return null;}
100
+ return hash(parts.sort().join('\n'));
101
+ }
102
+ function lockedCompiler(root,tsRoot,version,verify){
103
+ if(!/^(?:5\.\d+|6\.0)\.\d+$/.test(version))fail('direct-typescript-version-unsupported');
104
+ let locked;try{locked=lockedPackage(root,'typescript');}
105
+ catch(e){fail(['npm-lockfile-unreadable','npm-shrinkwrap-unsupported'].includes(e.code)?e.code:'direct-typescript-lockfile-integrity-missing');}
106
+ if(locked.version!==version)fail('direct-typescript-lockfile-version-differs');
107
+ if(!registryPackage(locked,'typescript',version)||
108
+ JSON.parse(fs.readFileSync(path.join(tsRoot,'package.json'),'utf8')).name!=='typescript')
109
+ fail('direct-typescript-lockfile-source-unsupported');
110
+ if(verify){
111
+ const tree=treeIdentity(tsRoot);
112
+ if(!tree||verifiedTrees.get(locked.integrity)!==tree){
113
+ matchTarball(tsRoot,tarballFiles(cachedTarball(npmCacheDir(root),locked.integrity)));
114
+ if(tree)verifiedTrees.set(locked.integrity,tree);
115
+ }
116
+ }
117
+ const entry=fs.existsSync(path.join(tsRoot,'lib','_tsc.js'))?'lib/_tsc.js':'lib/tsc.js';
118
+ return {tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
119
+ }
120
+ // The direct probe's output: root, script, configuration and program files with
121
+ // their byte digests, and the execution context. A recorded run rebuilds it from
122
+ // the same layout (direct-run-certificate.mjs).
123
+ export function directProbeOutput({root,script,configs,files,context,buildInfo}){
124
+ const named=list=>list.map(([file,digest])=>[hash(file),digest]);
125
+ return hash(JSON.stringify({root,script,configs:named(configs),files:named(files),...context,
126
+ ...(buildInfo?{buildInfo}:{})}));
127
+ }
128
+ // The build-info file an incremental root config makes tsc read and write,
129
+ // as {file} relative to the root, {} when not incremental, or {issue}. The
130
+ // probe and the plan both use this rule. It must be inside the checkout, end
131
+ // in .tsbuildinfo (the only write the run recorder allows) and contain no glob
132
+ // characters (the plan excludes it with a pattern). An option that makes the
133
+ // result depend on the build-info history is refused.
134
+ export function buildInfoFor(ts,options,root) {
135
+ if(!options?.incremental)return {};
136
+ if(options.assumeChangesOnlyAffectDirectDependencies)return {issue:'typescript-incremental-unsound'};
137
+ const file=ts.getTsBuildInfoEmitOutputFilePath?.(options);
138
+ if(!file||!withinPath(path.resolve(file),root))return {issue:'typescript-buildinfo-outside-repository'};
139
+ const relative=path.relative(root,path.resolve(file)).split(path.sep).join('/');
140
+ if(!relative.endsWith('.tsbuildinfo')||/[*?[\]{}()!+@,]/.test(relative))return {issue:'typescript-buildinfo-name-unsupported'};
141
+ return {file:relative};
142
+ }
143
+ // The config a run's arguments name, relative to the root, or null.
144
+ export const directConfig=(root,args)=>runConfig(root,args);
145
+ // The plan's build-info file, or null. The compiler is loaded only when the
146
+ // run's config sets incremental or extends another config.
147
+ export function directBuildInfo(root,run) {
148
+ const name=directConfig(root,run.args);if(!name)return null;
149
+ const config=path.join(root,name);
150
+ let raw;try{raw=fs.readFileSync(config,'utf8');}catch{return null;}
151
+ if(!/"(?:incremental|extends)"\s*:/.test(raw))return null;
152
+ const compiler=directProject(root,run,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
153
+ const parsed=ts.getParsedCommandLineOfConfigFile(config,{},{...ts.sys,onUnRecoverableConfigFileDiagnostic(){}});
154
+ return parsed?.options?.composite?null:buildInfoFor(ts,parsed?.options,root).file??null;
155
+ }
156
+ // The probe value the daemon stores for one direct probe's checkpoint output.
157
+ export function directProbeValue(argv,output,stderrHash){
158
+ return hash(JSON.stringify([[argv,0,hash(output),stderrHash]]));
159
+ }
160
+ // The execution context a direct probe certifies and a recorded run must match.
161
+ export function directContextHash(compiler,node){
162
+ return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
163
+ entry:compiler.entry,node}));
164
+ }
165
+ // The compiler for one check run {script, part, args}: script null for a
166
+ // check init proposed without one. The run must pass --noEmit and name a
167
+ // config inside the root.
168
+ export function directProject(root,run,{verify=true}={}){
169
+ const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
170
+ if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
171
+ !['package-lock.json','npm-shrinkwrap.json'].some(file=>fs.existsSync(path.join(root,file)))||
172
+ ['yarn.lock','pnpm-lock.yaml','bun.lock','bun.lockb'].some(file=>fs.existsSync(path.join(root,file))))
173
+ fail('direct-npm-project-unsupported');
174
+ const own=tscRun(pkg.scripts,run).run;
175
+ if(!own?.noEmit)fail('typecheck-script-changed');
176
+ const config=directConfig(root,run.args);
177
+ if(!config||!fs.existsSync(path.join(root,config)))fail('typescript-config-unavailable');
178
+ const issue=directEnvironmentIssue();if(issue)fail(issue);
179
+ return {...directCompiler(root,{verify}),config};
180
+ }
181
+ // Provenance only: retain a digest, never proxy URLs/credentials or a raw dump.
182
+ // No npm process runs in this recipe; these keys are not compiler applicability.
183
+ export function observedNpmContext(env=process.env){
184
+ return hash(JSON.stringify(Object.entries(env).filter(([key])=>/^npm_/i.test(key)||/^(?:https?_proxy|all_proxy|no_proxy)$/i.test(key)).sort(([a],[b])=>a<b?-1:a>b?1:0)));
185
+ }
package/src/glob.mjs CHANGED
@@ -7,3 +7,11 @@ export default {
7
7
  matcher(pattern,options){return picomatch(pattern,options);},
8
8
  isMatch(file,patterns,options){return picomatch(patterns,options)(file);}
9
9
  };
10
+ // A matcher for a pattern list with `!` exclusions: a path matches when a
11
+ // positive pattern does and no excluded one does. A plain list match would
12
+ // take any `!` pattern as matching everything else.
13
+ export function patternMatcher(patterns){
14
+ const positive=patterns.filter(p=>!p.startsWith('!')).map(p=>picomatch(p,{dot:true}));
15
+ const negative=patterns.filter(p=>p.startsWith('!')).map(p=>picomatch(p.slice(1),{dot:true}));
16
+ return file=>positive.some(fn=>fn(file))&&!negative.some(fn=>fn(file));
17
+ }
@@ -1,18 +1,26 @@
1
1
  // Native history is an optional acceleration. Keep its operations off the
2
2
  // control thread so a wedged native call cannot block status or shutdown.
3
+ // Each helper serves one query; the observer starts it ahead of that query.
3
4
  import fs from 'node:fs';
5
+ import {mark} from './decision-profile.mjs';
6
+ mark('history.worker_loaded');
4
7
  import watcher from '@parcel/watcher';
5
8
 
6
- try {
7
- const [root,previous,next,backend,faultFile]=process.argv.slice(2);
8
- let fault;
9
- try {fault=faultFile&&fs.readFileSync(faultFile,'utf8').trim();}
10
- catch(e){if(e.code!=='ENOENT')throw e;}
11
- if(fault==='hang')Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
12
- if(fault==='fail')throw Error('injected history failure');
13
- await watcher.writeSnapshot(root,next,{backend});
14
- const changes=previous?await watcher.getEventsSince(root,previous,{backend}):[];
15
- process.send({ok:true,changes});
16
- } catch(error) {
17
- process.send({ok:false,error:error.message,code:error.code||error.name});
18
- }
9
+ process.on('disconnect',()=>process.exit(0));
10
+ process.once('message',async({root,previous,next,backend,faultFile})=>{
11
+ try {
12
+ let fault;
13
+ try {fault=faultFile&&fs.readFileSync(faultFile,'utf8').trim();}
14
+ catch(e){if(e.code!=='ENOENT')throw e;}
15
+ if(fault==='hang')Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
16
+ if(fault==='fail')throw Error('injected history failure');
17
+ mark('history.snapshot_start');
18
+ await watcher.writeSnapshot(root,next,{backend});
19
+ mark('history.snapshot_complete');
20
+ const changes=previous?await watcher.getEventsSince(root,previous,{backend}):[];
21
+ mark('history.events_complete',{events:changes.length});
22
+ process.send({ok:true,changes});
23
+ } catch(error) {
24
+ process.send({ok:false,error:error.message,code:error.code||error.name});
25
+ }
26
+ });