@redue/cli 0.1.0-alpha.17 → 0.1.0-alpha.18

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
@@ -20,7 +20,9 @@ import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
20
20
  import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
21
21
  import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
22
22
  import {statusSummary} from './cached-state.mjs';
23
- import {directProject,directProbeValue,reviewedTypeScriptVersions} from './direct-typescript.mjs';
23
+ import {directProject,directProbeValue} from './direct-typescript.mjs';
24
+ import {npmCacheDir} from './npm-integrity.mjs';
25
+ import {runFromArgv} from './typescript-invocation.mjs';
24
26
  import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
25
27
  import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
26
28
 
@@ -130,7 +132,7 @@ function continuityBarrier() {
130
132
  // The basis a full direct probe is checked against: plan guard, observation
131
133
  // continuity, and the compiler tree and Node identity taken before the probe.
132
134
  function certificateBasis(row) {
133
- const argv=row.plan.probes[0],compiler=directProject(argv[2],argv[3],{verify:false});
135
+ const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
134
136
  return {guard:planGuard(index.bundle),gaps:metrics.gaps,
135
137
  compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
136
138
  toolchain:toolchainKey(compiler.tsRoot,argv[0])};
@@ -238,8 +240,18 @@ function probe(row,allowReuse=false,{launch=true}={}) {
238
240
  .includes(row.plan.qualification)?
239
241
  /VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
240
242
  const explanations={
241
- 'direct-typescript-implementation-unreviewed':'installed TypeScript bytes or membership differ from the reviewed compiler',
242
- 'direct-typescript-version-unsupported':`direct compiler recipe supports reviewed TypeScript ${reviewedTypeScriptVersions.join(', ')} only`,
243
+ 'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
244
+ 'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
245
+ 'direct-typescript-lockfile-source-unsupported':'package-lock.json installs TypeScript from somewhere other than the registry\'s typescript package (an alias, a file or a git source)',
246
+ 'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
247
+ 'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
248
+ 'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
249
+ 'npm-lockfile-unreadable':'package-lock.json cannot be read',
250
+ 'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
251
+ 'npm-tarball-uncached':`the TypeScript tarball package-lock.json names is not in npm's cache (${npmCacheDir(root)}); run npm ci or npm cache add`,
252
+ 'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
253
+ 'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
254
+ 'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
243
255
  'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
244
256
  'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
245
257
  'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
@@ -492,7 +504,7 @@ function adopt(name,row,token) {
492
504
  return done('inputs or certificate changed during the run');
493
505
  const cache=pending.cache,argv=row.plan.probes[0];
494
506
  let result;
495
- try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}),buildInfoPath(row.plan));}
507
+ try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
496
508
  catch{return done('recording unreadable');}
497
509
  if(result.issue)return done(result.issue);
498
510
  const identities=new Map(cache.identities);
@@ -1217,7 +1229,9 @@ const server=net.createServer(client=>{
1217
1229
  event('control_reply_unavailable',{classification:handled?'closed_without_reply':
1218
1230
  'closed_before_request',request_bytes:text.length});});
1219
1231
  client.on('data',d=>{
1220
- if(handled)return;text+=d;if(text.length>4096){event('control_request_rejected',
1232
+ // A sync request carries each check's context digests, about 1.5 KB per
1233
+ // check; 1 MiB leaves room for hundreds of checks.
1234
+ if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
1221
1235
  {classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
1222
1236
  if(!text.includes('\n'))return;handled=true;
1223
1237
  try{const message=JSON.parse(text);
@@ -9,6 +9,7 @@ import {directBuildInfo} from './direct-typescript.mjs';
9
9
  import {qualifyTests,testWorkspacePatterns,testsQualification} from './test-direct.mjs';
10
10
  import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
11
11
  import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
12
+ import {checkRun} from './typescript-invocation.mjs';
12
13
  import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
13
14
  import {timing} from './decision-profile.mjs';
14
15
 
@@ -64,8 +65,7 @@ export function discoverDeclared(config) {
64
65
  catch(e){workspaceIssue=e.message;}
65
66
  }
66
67
  if(selected.qualification==='pnpm-tsc-v1'){
67
- try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',
68
- selected.script||selected.command.at(-1));}
68
+ try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',checkRun(selected));}
69
69
  catch(e){workspaceIssue=e.message;}
70
70
  }
71
71
  timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
@@ -75,7 +75,7 @@ export function discoverDeclared(config) {
75
75
  // until then the run reports the unexpected write and is not reusable.
76
76
  let buildInfo=null;
77
77
  if(selected.qualification==='npm-typescript-direct-v1')
78
- try{buildInfo=directBuildInfo(root,selected.script);}catch{}
78
+ try{buildInfo=directBuildInfo(root,checkRun(selected));}catch{}
79
79
  const excluded=buildInfo?['!'+buildInfo]:[];
80
80
  const installedStarted=performance.now();
81
81
  const installed=resolveDeclaredInstalled(root,
@@ -4,18 +4,20 @@ import {typeScriptInputFacts} from './typescript-input-facts.mjs';
4
4
  import {contextOnly,probeResult} from './probe-result.mjs';
5
5
  import {realObservedPath} from './path-identity.mjs';
6
6
  import {timing} from './decision-profile.mjs';
7
+ import {runArgv,runFromArgv} from './typescript-invocation.mjs';
7
8
  try{
8
- const [rootArg,script]=process.argv.slice(2),root=realObservedPath(rootArg);
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(' ');
9
11
  // Verify all installed implementation bytes before loading compiler API/code,
10
12
  // including the optimized context-only path. A version label is insufficient.
11
13
  const implementationStarted=performance.now();
12
- const compiler=directProject(root,script);
14
+ const compiler=directProject(root,run);
13
15
  timing('direct_probe.implementation',implementationStarted,{version:compiler.version});
14
16
  const context={interpretation:directInterpretation,compiler:compiler.digest,
15
17
  entry:compiler.entry,node:[realObservedPath(process.execPath),process.version]};
16
18
  const contextHash=directContextHash(compiler,context.node);
17
19
  if(!contextOnly(contextHash)){
18
- const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry);
20
+ const {queries,configs,files,buildInfo}=typeScriptInputFacts(root,compiler.tsRoot,compiler.entry,compiler.config);
19
21
  const layout={root,script,configs,files,context,...(buildInfo?{buildInfo}:{})};
20
22
  probeResult(directProbeOutput(layout),contextHash,queries,layout);
21
23
  }
@@ -3,7 +3,9 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import {createHash} from 'node:crypto';
5
5
  import {createRequire} from 'node:module';
6
- import {realObservedPath,withinPath} from './path-identity.mjs';
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';
7
9
  export const directInterpretation='npm-direct-typescript@1';
8
10
  export const directQualification='npm-typescript-direct-v1';
9
11
  const reviewed={
@@ -60,8 +62,9 @@ export function directCompiler(root,{verify=true}={}){
60
62
  const tsRoot=path.dirname(realObservedPath(manifest));
61
63
  if(!withinPath(tsRoot,root)||tsRoot!==realObservedPath(path.join(root,'node_modules/typescript')))
62
64
  fail('direct-typescript-layout-unsupported');
63
- const version=JSON.parse(fs.readFileSync(manifest)).version,spec=reviewed[version];
64
- if(!spec)fail('direct-typescript-version-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];
65
68
  if(verify){
66
69
  const files=[];
67
70
  const walk=(dir,relative='')=>{
@@ -78,6 +81,42 @@ export function directCompiler(root,{verify=true}={}){
78
81
  }
79
82
  return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
80
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
+ }
81
120
  // The direct probe's output: root, script, configuration and program files with
82
121
  // their byte digests, and the execution context. A recorded run rebuilds it from
83
122
  // the same layout (direct-run-certificate.mjs).
@@ -101,13 +140,16 @@ export function buildInfoFor(ts,options,root) {
101
140
  if(!relative.endsWith('.tsbuildinfo')||/[*?[\]{}()!+@,]/.test(relative))return {issue:'typescript-buildinfo-name-unsupported'};
102
141
  return {file:relative};
103
142
  }
143
+ // The config a run's arguments name, relative to the root, or null.
144
+ export const directConfig=(root,args)=>runConfig(root,args);
104
145
  // 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');
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);
108
150
  let raw;try{raw=fs.readFileSync(config,'utf8');}catch{return null;}
109
151
  if(!/"(?:incremental|extends)"\s*:/.test(raw))return null;
110
- const compiler=directProject(root,script,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
152
+ const compiler=directProject(root,run,{verify:false}),ts=createRequire(import.meta.url)(compiler.tsRoot);
111
153
  const parsed=ts.getParsedCommandLineOfConfigFile(config,{},{...ts.sys,onUnRecoverableConfigFileDiagnostic(){}});
112
154
  return parsed?.options?.composite?null:buildInfoFor(ts,parsed?.options,root).file??null;
113
155
  }
@@ -120,16 +162,21 @@ export function directContextHash(compiler,node){
120
162
  return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
121
163
  entry:compiler.entry,node}));
122
164
  }
123
- export function directProject(root,script,{verify=true}={}){
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}={}){
124
169
  const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
125
170
  if(pkg.workspaces||pkg.packageManager&&!/^npm@/.test(pkg.packageManager)||
126
171
  !['package-lock.json','npm-shrinkwrap.json'].some(file=>fs.existsSync(path.join(root,file)))||
127
172
  ['yarn.lock','pnpm-lock.yaml','bun.lock','bun.lockb'].some(file=>fs.existsSync(path.join(root,file))))
128
173
  fail('direct-npm-project-unsupported');
129
- if(pkg.scripts?.[script]?.trim()!=='tsc --noEmit'||pkg.scripts?.['pre'+script]||pkg.scripts?.['post'+script])
130
- fail('typecheck-script-changed');
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');
131
178
  const issue=directEnvironmentIssue();if(issue)fail(issue);
132
- return directCompiler(root,{verify});
179
+ return {...directCompiler(root,{verify}),config};
133
180
  }
134
181
  // Provenance only: retain a digest, never proxy URLs/credentials or a raw dump.
135
182
  // No npm process runs in this recipe; these keys are not compiler applicability.
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
+ }
@@ -8,11 +8,12 @@ import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs
8
8
  import {realObservedPath} from './path-identity.mjs';
9
9
  import {findExecutable} from './executable-lookup.mjs';
10
10
  import {workspaceManifests} from './workspace-manifests.mjs';
11
+ import {partName,tscRuns} from './typescript-invocation.mjs';
11
12
 
12
13
  const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
13
14
  const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
14
15
  const scriptNames={
15
- typecheck:['typecheck','type-check','check:types','types:check'],
16
+ typecheck:['typecheck','type-check','check:types','types:check','check-types'],
16
17
  test:['test','test:unit','test:check'],
17
18
  lint:['lint','lint:check'],
18
19
  build:['build'],
@@ -82,6 +83,28 @@ function manager(root,pkg) {
82
83
  return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
83
84
  }
84
85
  const exactTypeScript=script=>script.trim()==='tsc --noEmit';
86
+ // A script of tsc runs that each pass --noEmit (typescript-invocation.mjs).
87
+ const noEmitRuns=script=>Boolean(tscRuns(script)?.every(run=>run.noEmit));
88
+ // Ready checks for a typecheck, one per tsc run: the script's runs (a chain
89
+ // gives NAME.1 ... NAME.n), or with no script one proposed `tsc --noEmit`.
90
+ // `make(run)` returns a run's command, cwd and inputs, or throws why its
91
+ // contract does not hold.
92
+ function tscChecks(name,script,scripts,make){
93
+ const runs=script===null?[{args:['--noEmit']}]:tscRuns(scripts[script]);
94
+ if(!runs)throw Error('the script is not tsc runs in a supported form');
95
+ return runs.map(({args},i)=>({name:partName(name,i+1,runs.length),
96
+ ...(script===null?{}:{script}),...make({script,args,count:runs.length})}));
97
+ }
98
+ // A proposed `tsc --noEmit` only where nothing typechecks already: a script
99
+ // that runs any TypeScript checker in another form (tsc -b, vue-tsc,
100
+ // svelte-check, tsgo, also quoted or wrapped by concurrently or run-p) is the
101
+ // project's own typecheck, which plain tsc would not match.
102
+ const typeChecker=/(?:^|[^\w-])(?:tsc|vue-tsc|tsgo|svelte-check|tsc-watch)(?=$|[^\w-])|\bastro check\b|\bnuxi typecheck\b/;
103
+ const typechecks=scripts=>Object.values(scripts||{}).some(text=>typeof text==='string'&&typeChecker.test(text));
104
+ const proposable=(dir,scripts)=>exists(path.join(dir,'tsconfig.json'))&&!typechecks(scripts);
105
+ // The inputs a TypeScript contract adds to its check's config: no exclusions,
106
+ // which would outlive the tsconfig they came from; the plan adds the current ones.
107
+ const contractInputs=contract=>[...contract.configs,...contract.sourcePatterns.filter(p=>!p.startsWith('!'))];
85
108
  function sourceLinkIssue(root) {
86
109
  const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
87
110
  let inspected=0;
@@ -139,14 +162,26 @@ function selectScript(kind,names,scripts) {
139
162
  const candidates=Object.keys(scripts).filter(name=>{
140
163
  const value=scripts[name]?.trim();
141
164
  if(typeof value!=='string')return false;
142
- if(kind==='typecheck')return exactTypeScript(value);
165
+ if(kind==='typecheck')return noEmitRuns(value);
143
166
  if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
144
167
  if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
145
168
  return /^vite\s+build(?:\s|$)/.test(value);
146
169
  });
170
+ // A lone plain `tsc --noEmit` wins over other tsc shapes beside it (a
171
+ // project's `typescript` next to `typescript:tests:raw`).
172
+ const exact=kind==='typecheck'?candidates.filter(name=>exactTypeScript(scripts[name])):[];
173
+ if(exact.length===1)return {script:exact[0]};
147
174
  return candidates.length===1?{script:candidates[0]}:
148
175
  candidates.length>1?{ambiguous:candidates}:{};
149
176
  }
177
+ // A pnpm workspace package's TypeScript run, as its check's command, cwd and inputs.
178
+ function pnpmCheck(root,name,dir,manifest,run){
179
+ const contract=pnpmTypecheckInputs(root,name,run);
180
+ if(contract.limitations.length)throw Error(contract.limitations.join('; '));
181
+ return {cwd:dir,command:['@node',`@typescript-bin:${dir}`,...run.args],
182
+ inputs:[...new Set(['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',manifest,
183
+ ...contractInputs(contract)])]};
184
+ }
150
185
  // Workspace packages as init finds them, with their scripts; [] without workspaces.
151
186
  export function workspacePackages(root){
152
187
  root=realObservedPath(root);
@@ -178,19 +213,27 @@ export function discoverNodeProject(root,{recipe=null}={}) {
178
213
  for(const [kind,names] of Object.entries(scriptNames)) {
179
214
  const selected=selectScript(kind,names,scripts),script=selected.script;
180
215
  if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
181
- if(!script)continue;
216
+ // With no typecheck script, a root tsconfig.json gets a proposed
217
+ // `tsc --noEmit` check; package.json stays unchanged. It is kept only
218
+ // when a contract makes it ready.
219
+ const proposed=!script&&!selected.ambiguous&&kind==='typecheck'&&!observationIssue&&
220
+ proposable(root,scripts);
221
+ if(!script&&!proposed)continue;
182
222
  let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
183
- classify(kind,script,scripts[script],pkg,pm,root);
223
+ script?classify(kind,script,scripts[script],pkg,pm,root):{level:'recording',reason:'no typecheck script'};
224
+ let parts=null;
184
225
  if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
185
226
  reason:'workspace task and installed-input closure need explicit qualification'};
186
227
  const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
187
228
  if(recipe==='typescript-direct'&&kind==='typecheck'){
188
229
  try{
189
230
  if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
190
- const compiler=directProject(root,script);
191
- check.command=['@node','@typescript-compiler:.','--noEmit'];check.cwd='.';
231
+ let version;
232
+ parts=tscChecks('typecheck',script??null,scripts,run=>{
233
+ version=directProject(root,run).version;
234
+ return {command:['@node','@typescript-compiler:.',...run.args],cwd:'.'};});
192
235
  classification={level:'ready',qualification:directQualification,
193
- reason:`explicit local TypeScript ${compiler.version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
236
+ reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
194
237
  }catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
195
238
  }
196
239
  if(recipe==='tests-direct'&&kind==='test'){
@@ -207,20 +250,24 @@ export function discoverNodeProject(root,{recipe=null}={}) {
207
250
  reason:`explicit local ${project.runner} ${project.version} run of script ${JSON.stringify(chosen.script)}; the whole checkout is its input; npm and lifecycle scripts are not executed`};
208
251
  }catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
209
252
  }
210
- if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
211
- /^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(scripts[script].trim())){
212
- try{const contract=pnpmTypecheckInputs(root,'.',script);
213
- if(contract.limitations.length)throw Error(contract.limitations.join('; '));
214
- check.command=['@node','@typescript-bin:.',
215
- ...scripts[script].trim().split(/\s+/).slice(1)];
216
- check.cwd='.';
217
- check.inputs=['package.json','pnpm-lock.yaml',
218
- ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
219
- 'tsconfig.json','src/**'];
253
+ if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
254
+ try{
255
+ parts=tscChecks('typecheck',script??null,scripts,run=>{
256
+ const contract=pnpmTypecheckInputs(root,'.',run);
257
+ if(contract.limitations.length)throw Error(contract.limitations.join('; '));
258
+ return {command:['@node','@typescript-bin:.',...run.args],cwd:'.',
259
+ inputs:[...new Set(['package.json','pnpm-lock.yaml',
260
+ ...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
261
+ ...contractInputs(contract)])]};});
220
262
  classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
221
263
  qualification:'pnpm-tsc-v1'};}
222
264
  catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
223
265
  }
266
+ if(!script&&classification.level!=='ready')continue;
267
+ if(parts&&classification.level==='ready'){
268
+ for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
269
+ continue;
270
+ }
224
271
  if(classification.qualification)check.qualification=classification.qualification;
225
272
  checks.push({config:check,...classification});
226
273
  }
@@ -238,6 +285,16 @@ export function discoverNodeProject(root,{recipe=null}={}) {
238
285
  const selected=selectScript(kind,names,member.scripts||{});
239
286
  if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
240
287
  scripts:selected.ambiguous});continue;}
288
+ // A pnpm package with a tsconfig.json and no typecheck script gets a
289
+ // proposed `tsc --noEmit` check when the pnpm contract holds.
290
+ if(!selected.script&&kind==='typecheck'&&pm.name==='pnpm'&&!pm.issue&&
291
+ proposable(path.join(root,dir),member.scripts)&&!typechecks(scripts)){
292
+ try{for(const part of tscChecks(`${name}:typecheck`,null,{},run=>pnpmCheck(root,name,dir,file,run)))
293
+ checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
294
+ reason:'proposed tsc --noEmit for the package\'s tsconfig.json; pnpm workspace TypeScript inputs; checked again before reuse'});}
295
+ catch{}
296
+ continue;
297
+ }
241
298
  if(!selected.script)continue;
242
299
  const command=pm.name==='npm'?['@which:npm','run',selected.script,
243
300
  '--workspace',name]:pm.name==='yarn'?
@@ -258,20 +315,11 @@ export function discoverNodeProject(root,{recipe=null}={}) {
258
315
  level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
259
316
  }catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
260
317
  }
261
- if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
262
- /^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(
263
- (member.scripts||{})[selected.script]?.trim()||'')&&
264
- !member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
265
- try{const contract=pnpmTypecheckInputs(root,name,selected.script);
266
- if(contract.limitations.length)throw Error(contract.limitations.join('; '));
267
- check.workspace=name;check.qualification='pnpm-tsc-v1';
268
- check.script=selected.script;
269
- check.cwd=dir;
270
- check.command=['@node',`@typescript-bin:${dir}`,
271
- ...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
272
- check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
273
- `${dir}/src/**`];
274
- level='ready';reason='pnpm workspace TypeScript inputs; checked again before reuse';
318
+ if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&tscRuns((member.scripts||{})[selected.script])){
319
+ try{const parts=tscChecks(`${name}:typecheck`,selected.script,member.scripts,run=>pnpmCheck(root,name,dir,file,run));
320
+ for(const part of parts)checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
321
+ reason:'pnpm workspace TypeScript inputs; checked again before reuse'});
322
+ continue;
275
323
  }catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
276
324
  }
277
325
  if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
@@ -0,0 +1,124 @@
1
+ // An installed npm package checked against what the lockfile recorded: the
2
+ // tarball npm cached under the lockfile's integrity (sha512, else sha1),
3
+ // unpacked in memory and compared file by file. Nothing is fetched.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import zlib from 'node:zlib';
8
+ import {createHash} from 'node:crypto';
9
+ import {npmrc} from './npmrc.mjs';
10
+ import {racyUntil} from './path-identity.mjs';
11
+
12
+ const fail=code=>{throw Object.assign(Error(code),{code});};
13
+ // npm's cache directory, as npm picks it: npm_config_cache, else `cache` in
14
+ // the project's .npmrc, then the user's (npm_config_userconfig or
15
+ // ~/.npmrc), else npm's default. A leading ~ is the home directory. The
16
+ // global npmrc is not read.
17
+ export function npmCacheDir(root=null,env=process.env,platform=process.platform,home=os.homedir()){
18
+ const variable=name=>Object.entries(env).find(([key])=>key.toLowerCase()===name)?.[1];
19
+ const expand=value=>path.resolve(value.replace(/^~(?=$|[\/])/,home));
20
+ const configured=variable('npm_config_cache');
21
+ if(configured)return expand(configured);
22
+ for(const file of [root&&path.join(root,'.npmrc'),variable('npm_config_userconfig')||path.join(home,'.npmrc')].filter(Boolean)){
23
+ let cache;try{cache=npmrc(file).cache;}catch{}
24
+ if(typeof cache==='string'&&cache)return expand(cache);
25
+ }
26
+ return platform==='win32'?path.join(env.LOCALAPPDATA||path.join(home,'AppData','Local'),'npm-cache'):path.join(home,'.npm');
27
+ }
28
+ // The package-lock.json entry for an installed top-level package:
29
+ // {version, integrity, resolved, alias}. npm-shrinkwrap.json, which npm reads
30
+ // instead, is not modeled. Parsed entries are kept per process by the
31
+ // lockfile's identity: the daemon asks on every reuse check.
32
+ const lockfiles=new Map();
33
+ export function lockedPackage(root,name){
34
+ if(fs.existsSync(path.join(root,'npm-shrinkwrap.json')))fail('npm-shrinkwrap-unsupported');
35
+ const file=path.join(root,'package-lock.json');
36
+ let st;try{st=fs.statSync(file,{bigint:true});}catch{fail('npm-lockfile-integrity-missing');}
37
+ const identity=[st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':');
38
+ let packages=lockfiles.get(file)?.identity===identity?lockfiles.get(file).packages:null;
39
+ if(!packages){
40
+ try{packages=JSON.parse(fs.readFileSync(file,'utf8'))?.packages;}catch{fail('npm-lockfile-unreadable');}
41
+ // A lockfile changed within the racy window is read again next time.
42
+ if(racyUntil(st)<=Date.now())lockfiles.set(file,{identity,packages});
43
+ }
44
+ const entry=packages?.[`node_modules/${name}`];
45
+ if(!entry||typeof entry.version!=='string'||typeof entry.integrity!=='string')fail('npm-lockfile-integrity-missing');
46
+ return {version:entry.version,integrity:entry.integrity,resolved:entry.resolved??null,alias:entry.name??null};
47
+ }
48
+ // Whether a lockfile entry is the registry's own package NAME at VERSION:
49
+ // no alias, and a tarball URL .../NAME/-/NAME-VERSION.tgz over https.
50
+ export const registryPackage=(entry,name,version)=>entry.alias===null&&typeof entry.resolved==='string'&&
51
+ /^https:\/\//.test(entry.resolved)&&entry.resolved.endsWith(`/${name}/-/${name}-${version}.tgz`);
52
+ // The digest an SRI string names, as {algorithm, hex}: sha512, else sha1
53
+ // (some registries record only sha1; a collision needs a malicious party,
54
+ // which is outside REDUE's threat model). null for anything else.
55
+ export function sriDigest(integrity){
56
+ const items=String(integrity).split(/\s+/);
57
+ for(const [algorithm,length] of [['sha512',64],['sha1',20]]){
58
+ const value=items.map(item=>new RegExp(`^${algorithm}-([A-Za-z0-9+/]+={0,2})$`).exec(item)?.[1]).find(Boolean);
59
+ const bytes=value&&Buffer.from(value,'base64');
60
+ if(bytes?.length===length)return {algorithm,hex:bytes.toString('hex')};
61
+ }
62
+ return null;
63
+ }
64
+ // The cached tarball for an integrity, checked against it.
65
+ export function cachedTarball(cache,integrity){
66
+ const digest=sriDigest(integrity);
67
+ if(!digest)fail('npm-lockfile-integrity-unsupported');
68
+ const {algorithm,hex}=digest;
69
+ let bytes;
70
+ try{bytes=fs.readFileSync(path.join(cache,'_cacache','content-v2',algorithm,hex.slice(0,2),hex.slice(2,4),hex.slice(4)));}
71
+ catch{fail('npm-tarball-uncached');}
72
+ if(createHash(algorithm).update(bytes).digest('hex')!==hex)fail('npm-tarball-corrupt');
73
+ return bytes;
74
+ }
75
+ // The regular files of a gzipped npm tarball, as Map(path below the top
76
+ // directory -> bytes). Links and special files are refused.
77
+ export function tarballFiles(gzipped){
78
+ let tar;try{tar=zlib.gunzipSync(gzipped,{maxOutputLength:512*1024*1024});}catch{fail('npm-tarball-invalid');}
79
+ const files=new Map();
80
+ const text=(block,from,to)=>block.subarray(from,to).toString('utf8').replace(/\0[\s\S]*$/,'');
81
+ let offset=0,longName=null,paxPath=null;
82
+ while(offset+512<=tar.length){
83
+ const header=tar.subarray(offset,offset+512);
84
+ if(header.every(byte=>byte===0))break;
85
+ const size=parseInt(text(header,124,136).trim()||'0',8),type=String.fromCharCode(header[156]||48);
86
+ if(!Number.isSafeInteger(size)||size<0)fail('npm-tarball-invalid');
87
+ const body=tar.subarray(offset+512,offset+512+size);
88
+ offset+=512+Math.ceil(size/512)*512;
89
+ if(type==='x'){
90
+ // pax records: "LEN key=value\n"
91
+ for(const record of body.toString('utf8').split('\n')){const match=/^\d+ path=(.*)$/.exec(record);if(match)paxPath=match[1];}
92
+ continue;
93
+ }
94
+ if(type==='g')continue;
95
+ if(type==='L'){longName=body.toString('utf8').replace(/\0[\s\S]*$/,'');continue;}
96
+ const prefix=text(header,345,500);
97
+ const name=paxPath??longName??(prefix?`${prefix}/${text(header,0,100)}`:text(header,0,100));
98
+ paxPath=null;longName=null;
99
+ if(type==='5')continue;
100
+ if(!['0','7'].includes(type))fail('npm-tarball-link-unsupported');
101
+ // npm strips the top directory (usually package/).
102
+ const relative=name.split('/').slice(1).join('/');
103
+ if(!relative||relative.split('/').some(part=>part===''||part==='.'||part==='..'))fail('npm-tarball-invalid');
104
+ files.set(relative,body);
105
+ }
106
+ return files;
107
+ }
108
+ // Whether `dir` holds exactly the tarball's files, byte for byte; throws
109
+ // otherwise. About 120 ms for TypeScript 6 (140 files, 24 MB).
110
+ export function matchTarball(dir,files){
111
+ let count=0;
112
+ const walk=(folder,relative='')=>{
113
+ let entries;try{entries=fs.readdirSync(folder,{withFileTypes:true});}catch{fail('direct-typescript-implementation-unreviewed');}
114
+ for(const entry of entries){
115
+ const name=relative?`${relative}/${entry.name}`:entry.name,full=path.join(folder,entry.name);
116
+ if(entry.isDirectory()){walk(full,name);continue;}
117
+ const expected=files.get(name);
118
+ let same=false;try{same=entry.isFile()&&Boolean(expected)&&fs.readFileSync(full).equals(expected);}catch{}
119
+ if(!same)fail('direct-typescript-implementation-unreviewed');
120
+ count++;
121
+ }
122
+ };walk(dir);
123
+ if(count!==files.size)fail('direct-typescript-implementation-unreviewed');
124
+ }
package/src/npmrc.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // An .npmrc file read as npm reads ini: comments, a BOM, `key = value`, a bare
2
+ // key as true, quotes stripped, true/false as booleans. {} when it is absent.
3
+ import fs from 'node:fs';
4
+
5
+ export function npmrc(file){
6
+ const settings={};
7
+ if(!fs.existsSync(file))return settings;
8
+ for(const line of fs.readFileSync(file,'utf8').replace(/^\uFEFF/,'').split(/\r?\n/)){
9
+ const trimmed=line.trim();if(!trimmed||/^[#;]/.test(trimmed))continue;
10
+ const at=trimmed.indexOf('='),key=(at<0?trimmed:trimmed.slice(0,at)).trim();
11
+ let value=at<0?true:trimmed.slice(at+1).trim();
12
+ if(typeof value==='string'&&/^(["']).*\1$/.test(value))value=value.slice(1,-1);
13
+ settings[key]=value==='true'?true:value==='false'?false:value;
14
+ }
15
+ return settings;
16
+ }
@@ -5,6 +5,7 @@ import {createHash} from 'node:crypto';
5
5
  import {discover} from './plan.mjs';
6
6
  import {InputIndex,dehydrateIndex} from './index.mjs';
7
7
  import {directProject,directQualification} from './direct-typescript.mjs';
8
+ import {checkRun} from './typescript-invocation.mjs';
8
9
  import {queriesMatch} from './typescript-list.mjs';
9
10
  import {planGuard,inputKey} from './decision-validation.mjs';
10
11
  import {timing,traceBase} from './decision-profile.mjs';
@@ -25,8 +26,10 @@ try {
25
26
  const queryStarted=performance.now();
26
27
  // Cached membership queries load the compiler API; establish its reviewed
27
28
  // implementation before that optimization can execute project code.
29
+ // Every direct check (a chain has one per run) uses the root's compiler:
30
+ // its bytes are verified once.
28
31
  const directRoots=new Set(config.checks.filter(check=>check.qualification===directQualification)
29
- .map(check=>directProject(config.root,check.script).tsRoot));
32
+ .map((check,i)=>directProject(config.root,checkRun(check),{verify:i===0}).tsRoot));
30
33
  if(directRoots.size&&(workerData.reuse.queries||[]).some(([kind,,,options])=>
31
34
  kind==='readDirectory'&&!directRoots.has(options?.tsRoot)))throw Error('retained compiler implementation changed');
32
35
  const queriesValid=guard===workerData.reuse.guard&&