@redue/cli 0.1.0-alpha.16 → 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.
@@ -10,6 +10,7 @@ import {spawn} from 'node:child_process';
10
10
  import {projectState} from './project-location.mjs';
11
11
  import {realObservedPath} from './path-identity.mjs';
12
12
  import {processAlive} from './process-liveness.mjs';
13
+ import {scriptRunChecks} from './typescript-invocation.mjs';
13
14
 
14
15
  export const hookCommand='redue agent hook claude';
15
16
  // No shell metacharacters; `@` starts scoped package names.
@@ -91,29 +92,65 @@ const checkScript=check=>typeof check.script==='string'?check.script:
91
92
  Array.isArray(check.command)&&/^@which:(?:npm|pnpm|yarn)$/.test(check.command[0])&&check.command.includes('run')?
92
93
  check.command[check.command.indexOf('run')+1]:null;
93
94
 
95
+ // A typecheck check whose command is the compiler: its arguments are one tsc
96
+ // run of its script (typescript-invocation.mjs).
97
+ function tscCheck(check){return typecheck(check)&&/^tsc(?: |$)/.test(toolText(check)||'');}
94
98
  // Exact commands that rerun each configured typecheck or direct test check.
95
99
  // `forms` work from the project root; `local` ones work in the check's own
96
100
  // directory `dir`, after a `cd` into it. Names that would need shell quoting
97
- // get no form, so the hook never fires for them.
101
+ // get no form, so the hook never fires for them. The script forms of compiler
102
+ // checks name the script's checks as {checks, script}: the hook acts on them
103
+ // only when the script's tsc runs are those checks' runs, one each. With one
104
+ // check they stay on its row (`scriptForms`); a chain's checks get a row for
105
+ // the script, and their own rows (`part`) keep only their own forms.
98
106
  export function hookForms(config,configFile=defaultConfig){
99
- const rows=[],redue=redueFor(configFile),pm=config?.packageManager;
100
- for(const check of config?.checks||[]){
101
- if(!(typecheck(check)||tests(check))||typeof check.name!=='string'||!safe.test(check.name))continue;
102
- const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),script=checkScript(check);
107
+ const rows=[],redue=redueFor(configFile),pm=config?.packageManager,scripts=new Map();
108
+ const eligible=(config?.checks||[]).filter(check=>(typecheck(check)||tests(check))&&typeof check.name==='string'&&safe.test(check.name));
109
+ const groupOf=check=>tscCheck(check)&&typeof checkScript(check)==='string'&&checkDir(check)!==null?
110
+ JSON.stringify([checkDir(check),checkScript(check),workspaceName(check)]):null;
111
+ for(const check of eligible){const key=groupOf(check);if(key)scripts.set(key,[...scripts.get(key)||[],check.name]);}
112
+ const scriptRow=(dir,script,workspace)=>({forms:dir==='.'?scriptForms(pm,script):dir!==null&&safe.test(dir)?workspaceForms(pm,workspace,script):[],
113
+ local:dir!=='.'&&dir!==null&&safe.test(dir)?scriptForms(pm,script):[]});
114
+ for(const check of eligible){
115
+ const forms=redue?[`${redue} run ${check.name}`]:[],local=[],dir=checkDir(check),key=groupOf(check),
116
+ group=key?scripts.get(key):null,part=group?.length>1;
117
+ const own=group?null:checkScript(check);
103
118
  if(dir==='.'){
104
- forms.push(...scriptForms(pm,script),...toolForms(pm,toolText(check)));
119
+ forms.push(...scriptForms(pm,own),...toolForms(pm,toolText(check)));
105
120
  if(!tests(check)&&Array.isArray(check.command)&&check.command.length&&
106
121
  check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
107
122
  forms.push(check.command.join(' '));
108
123
  }else if(dir!==null&&safe.test(dir)){
109
- forms.push(...workspaceForms(pm,workspaceName(check),script));
110
- local.push(...scriptForms(pm,script),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
124
+ forms.push(...workspaceForms(pm,workspaceName(check),own));
125
+ local.push(...scriptForms(pm,own),...(check.cwd===dir?toolForms(pm,toolText(check)):[]));
111
126
  }
127
+ let extra={};
128
+ if(group&&!part){
129
+ const {forms:scriptOnly,local:scriptLocal}=scriptRow(dir,checkScript(check),workspaceName(check));
130
+ forms.splice(redue?1:0,0,...scriptOnly);local.unshift(...scriptLocal);
131
+ extra={checks:group,script:checkScript(check),scriptForms:[...scriptOnly,...scriptLocal]};
132
+ }
133
+ if(part)extra={part:true};
112
134
  if(forms.length||local.length)rows.push({check:check.name,kind:checkKind(check),dir,
113
- forms:[...new Set(forms)],local:[...new Set(local)]});
135
+ forms:[...new Set(forms)],local:[...new Set(local)],...extra});
136
+ }
137
+ for(const [key,checks] of scripts){
138
+ if(checks.length<2)continue;
139
+ const [dir,script,workspace]=JSON.parse(key),{forms,local}=scriptRow(dir,script,workspace);
140
+ if(forms.length||local.length)rows.push({check:checks[0].replace(/\.\d+$/,''),checks,script,
141
+ scriptForms:[...forms,...local],kind:'typecheck',dir,forms,local});
114
142
  }
115
143
  return rows;
116
144
  }
145
+ // The checks a compiler script row runs, in the order of the script's runs in
146
+ // the package's package.json now; null when they no longer correspond (the
147
+ // script changed since init), so the hook leaves the command alone.
148
+ export function scriptChecks(row,config,projectRoot){
149
+ let scripts;try{scripts=JSON.parse(fs.readFileSync(path.join(projectRoot,row.dir,'package.json'),'utf8')).scripts;}catch{return null;}
150
+ if(scripts?.['pre'+row.script]||scripts?.['post'+row.script])return null;
151
+ const args=name=>config.checks.find(check=>check.name===name)?.command?.slice(2);
152
+ return scriptRunChecks(scripts?.[row.script],row.checks.map(name=>({name,args:args(name)})));
153
+ }
117
154
 
118
155
  // A turbo or `pnpm -r` command that runs one task in every workspace package:
119
156
  // {tool, task}. No filters and no nx: both can add packages or tasks that only
@@ -166,10 +203,17 @@ export async function fanoutChecks(fan,config,projectRoot){
166
203
  return null;
167
204
  const names=[];
168
205
  for(const item of packages){
169
- const check=config.checks.find(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
206
+ const own=config.checks.filter(c=>(typecheck(c)||tests(c))&&typeof c.name==='string'&&
170
207
  workspaceName(c)===item.name&&checkScript(c)===fan.task&&checkDir(c)===item.dir);
171
- if(!check)return null;
172
- names.push(check.name);
208
+ if(!own.length)return null;
209
+ // A compiler script's checks: one per tsc run it has now.
210
+ if(own.every(tscCheck)){
211
+ const ordered=scriptRunChecks(item.scripts[fan.task],own.map(c=>({name:c.name,args:c.command.slice(2)})));
212
+ if(!ordered)return null;
213
+ names.push(...ordered);continue;
214
+ }
215
+ if(own.length!==1)return null;
216
+ names.push(own[0].name);
173
217
  }
174
218
  return names;
175
219
  }
@@ -199,18 +243,23 @@ function matched(command,config,configFile,dir='.',scripts={}){
199
243
  return scripts['pre'+name]||scripts['post'+name]?null:{fanout:fanout(text.trim()),rest:parts.rest};
200
244
  if(fanout(parts.base))return {fanout:fanout(parts.base),rest:parts.rest};
201
245
  }
202
- const check=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
203
- row.dir===dir&&row.local.includes(parts.base))?.check;
204
- return check?{check,rest:parts.rest}:null;
246
+ const row=hookForms(config,configFile).find(row=>dir==='.'?row.forms.includes(parts.base):
247
+ row.dir===dir&&row.local.includes(parts.base));
248
+ // A script form of compiler checks is checked against the script first.
249
+ return row?.scriptForms?.includes(parts.base)?{row,rest:parts.rest}:row?{check:row.check,rest:parts.rest}:null;
205
250
  }
206
- export function matchCheck(command,config,configFile,dir,scripts){return matched(command,config,configFile,dir,scripts)?.check||null;}
207
- // The same check through REDUE, keeping the output suffix; null if it already
208
- // is, or if more commands follow it.
251
+ export function matchCheck(command,config,configFile,dir,scripts){
252
+ const match=matched(command,config,configFile,dir,scripts);
253
+ return match?.row?match.row.checks.join(' && '):match?.check||null;
254
+ }
255
+ // The same check (or checks, in order) through REDUE, keeping the output
256
+ // suffix; null if it already is, or if more commands follow it.
209
257
  export function recordedCommand(command,name,configFile=defaultConfig){
258
+ const names=Array.isArray(name)?name:[name];
210
259
  const parts=split(command),redue=redueFor(configFile);
211
- if(!parts||parts.rest!==null||!safe.test(name)||!redue)return null;
212
- const run=`${redue} run ${name}`;
213
- return parts.base===run?null:run+parts.suffix;
260
+ if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
261
+ const run=names.map(value=>`${redue} run ${value}`).join(' && ');
262
+ return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
214
263
  }
215
264
 
216
265
  // A leading `cd DIR;` or `cd DIR &&`, which agents often add before a check:
@@ -277,9 +326,11 @@ export function statusTimeout(status,name){
277
326
  Math.min(run.durationMs/2,10000):2000;
278
327
  }
279
328
  // From the observer's last published status, which is small, not the receipts.
280
- export function lastRunTimeout(config,parsed,name){
329
+ // The longest wait among `names`, read once.
330
+ export function lastRunTimeout(config,parsed,...names){
281
331
  try{const root=realObservedPath(path.resolve(path.dirname(config),parsed.root||'.'));
282
- return statusTimeout(JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8')),name);}
332
+ const status=JSON.parse(fs.readFileSync(path.join(projectState(root,config),'status.json'),'utf8'));
333
+ return Math.max(...names.map(name=>statusTimeout(status,name)));}
283
334
  catch{return 2000;}
284
335
  }
285
336
 
@@ -329,10 +380,34 @@ export async function claudeHook(input,{entry,timeout}){
329
380
  if(match.fanout){
330
381
  const names=await fanoutChecks(match.fanout,parsed,project.projectRoot);
331
382
  if(!names)return null;
332
- const status=await synchronizedStatus(entry,config,root,timeout??Math.max(...names.map(name=>lastRunTimeout(config,parsed,name)))),
383
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
333
384
  reason=fanoutVerdict(status,names,match.rest);
334
385
  return reason?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}:null;
335
386
  }
387
+ // The configured config path as the command's directory sees it.
388
+ // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
389
+ const configPath=!entered.prefix?record.config:
390
+ path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/');
391
+ // A compiler script: its checks in the order of its runs, or nothing when
392
+ // they no longer correspond. One run is an ordinary check. A chain is
393
+ // skipped when every run is CURRENT, else the runs that are not run through
394
+ // redue run, in order.
395
+ if(match.row){
396
+ const names=scriptChecks(match.row,parsed,project.projectRoot);
397
+ if(!names)return null;
398
+ if(names.length===1)match.check=names[0];
399
+ else{
400
+ const status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,...names)),
401
+ reason=fanoutVerdict(status,names,match.rest);
402
+ if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
403
+ const pending=names.filter(name=>!verdict(status,name));
404
+ const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
405
+ names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
406
+ return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
407
+ permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
408
+ updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
409
+ }
410
+ }
336
411
  const name=match.check,status=await synchronizedStatus(entry,config,root,timeout??lastRunTimeout(config,parsed,name)),
337
412
  reason=verdict(status,name,match.rest);
338
413
  if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
@@ -342,9 +417,7 @@ export async function claudeHook(input,{entry,timeout}){
342
417
  // stays too: the rewrite returns "allow", which would also skip the prompt
343
418
  // for the rest of the chain.
344
419
  const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
345
- status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,
346
- // Both sides resolved alike: Windows can spell the same directory RUNNER~1 or by its long name.
347
- !entered.prefix?record.config:path.relative(path.join(project.projectRoot,entered.dir),realObservedPath(config)).split(path.sep).join('/')):null;
420
+ status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
348
421
  // updatedInput replaces the whole tool input. Deny and ask rules still apply
349
422
  // to the rewritten command; "allow" skips only the prompt.
350
423
  return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
5
5
  import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
6
6
  import {runFactChange} from './typescript-list.mjs';
7
- import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testProject,testEnvironmentIssue,testGuardConfig,testRunGuard,testsInterpretation,testsQualification} from './test-direct.mjs';
7
+ import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
10
  import os from 'node:os';
@@ -273,14 +273,17 @@ async function execute() {
273
273
  ...command.slice(1)]:command;
274
274
  // A test run's processes record what the guard checks (test-recorder.cjs).
275
275
  const testRun=selected.qualification===testsQualification&&['darwin','linux'].includes(process.platform);
276
- let testRecord=null,launchEnv=process.env;
276
+ let testRecord=null,launchEnv=process.env,testFacts={};
277
277
  if(testRun){
278
+ // The package directory and installed node_modules, as the plan has them.
279
+ try{testFacts=testRunFacts(root,selected);}
280
+ catch(e){captureIssues.push(`test run facts unavailable (${e.code||e.message})`);}
278
281
  const environmentIssue=testEnvironmentIssue();
279
282
  if(environmentIssue)captureIssues.push(environmentIssue);
280
283
  testRecord=path.join(state,`test-record-${runId}`);
281
284
  fs.mkdirSync(testRecord,{mode:0o700});
282
285
  const guardFile=path.join(testRecord,'guard.json');
283
- fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root)),{mode:0o600});
286
+ fs.writeFileSync(guardFile,JSON.stringify(testGuardConfig(testRecord,root,process.env,testFacts)),{mode:0o600});
284
287
  launchEnv=recordedEnvironment(guardFile);
285
288
  }
286
289
  let runnerPid=null;
@@ -354,10 +357,12 @@ async function execute() {
354
357
  }else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
355
358
  let guard=null;
356
359
  if(testRecord){
357
- let coverage=null;try{coverage=coverageReadable(root,testProject(root,selected.script));}catch{}
360
+ let coverage=null;try{coverage=coverageReadable(root,testFacts);}catch{}
358
361
  guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
359
362
  runner:runnerPid,environment:selected.environment||{},coverage});
360
363
  runFacts=guard.runFacts;
364
+ const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
365
+ if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
361
366
  fs.rmSync(testRecord,{recursive:true,force:true});
362
367
  }
363
368
  // The daemon reads the recording from owned state; control requests are small.
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',
@@ -271,7 +283,7 @@ function probe(row,allowReuse=false,{launch=true}={}) {
271
283
  'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
272
284
  ,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
273
285
  ,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
274
- ,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
286
+ ,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
275
287
  ,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
276
288
  ,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
277
289
  ,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
@@ -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);
@@ -6,9 +6,10 @@ import {createHash} from 'node:crypto';
6
6
  import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
7
7
  import {qualifyTypeScript} from './typescript-qualification.mjs';
8
8
  import {directBuildInfo} from './direct-typescript.mjs';
9
- import {qualifyTests,testsQualification} from './test-direct.mjs';
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,
@@ -92,6 +92,13 @@ export function discoverDeclared(config) {
92
92
  ...(workspaceIssue?[workspaceIssue]:[])];
93
93
  for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
94
94
  for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
95
+ // A workspace test check is qualified against the current package list: a
96
+ // package manifest appearing or pnpm's package list changing re-plans.
97
+ if(selected.qualification===testsQualification&&selected.workspace)try{
98
+ for(const item of testWorkspacePatterns(root,JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'))))
99
+ workspacePatterns.add(item);
100
+ if(fs.existsSync(path.join(root,'pnpm-workspace.yaml')))configurationInputs.add('pnpm-workspace.yaml');
101
+ }catch{}
95
102
  for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
96
103
  if(!automatic)for(const category of ['source','generated','installedDependencies',
97
104
  'environment','toolchain','runtime'])if(!selected.coverage?.[category])
@@ -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
+ }
package/src/index.mjs CHANGED
@@ -42,24 +42,33 @@ const pathPrefixes = rel => {
42
42
  // tick at the same size; such files are tracked in `racy`, and the daemon waits
43
43
  // the window out and records them again before it writes a run checkpoint.
44
44
  const identity=st=>identityFields.map(key=>String(st[key])).join(':');
45
+ // An installed link to a directory (pnpm links every package this way) is
46
+ // observed by its link chain alone, when `observedDir` says the directory it
47
+ // resolves to is itself observed installed: its own entries carry its contents.
45
48
  function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
46
- {bigint:true})) {
49
+ {bigint:true}),observedDir=null) {
47
50
  const file=path.join(root, rel);
48
51
  let value,references=[],bytes=installed?0:Number(before.size),racy=0;
49
52
  if(before.isSymbolicLink()){
50
53
  const resolution=resolveLinks(file,root,[],true);
51
54
  if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
52
55
  const target=fs.statSync(resolution.physical,{bigint:true});
53
- if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
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);
56
+ const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
57
+ const directory=target.isDirectory()&&installed&&observedDir?.(physical);
58
+ if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel}`);
59
+ if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
60
+ else{
61
+ if(installed)racy=racyUntil(target);else bytes=Number(target.size);
62
+ value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
63
+ target.mode+':'+hashRegular(resolution.physical).hash);
64
+ }
57
65
  references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
58
66
  .split(path.sep).join('/')),path.relative(root,resolution.physical)
59
67
  .split(path.sep).join('/')])];
60
- const targetAfter=fs.statSync(resolution.physical,{bigint:true});
61
- for(const key of identityFields)
62
- if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
68
+ // A directory's own timestamps follow its entries, which are observed separately.
69
+ if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
70
+ for(const key of identityFields)
71
+ if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
63
72
  const resolutionAfter=resolveLinks(file,root,[],true);
64
73
  if(resolutionAfter.physical!==resolution.physical||
65
74
  JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
@@ -181,7 +190,14 @@ export class InputIndex {
181
190
  catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
182
191
  if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
183
192
  const installed=this.installedPath(rel);
184
- try{const started=performance.now();inspection=fileHash(this.root,rel,installed,stat);
193
+ try{const started=performance.now();
194
+ // A linked directory counts as observed only strictly inside an
195
+ // installed root, and only if every check matching the link also
196
+ // observes the directory's own files (its package.json stands for them).
197
+ const observedDir=installed?physical=>!this.includeInstalled&&
198
+ [...this.installedRootSet].some(root=>physical.startsWith(root+'/'))&&
199
+ matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
200
+ inspection=fileHash(this.root,rel,installed,stat,observedDir);
185
201
  fresh=inspection.hash;
186
202
  this.profile.hashingMs+=performance.now()-started;
187
203
  this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
@@ -302,9 +318,10 @@ export class InputIndex {
302
318
  const full=path.join(this.root,rel);
303
319
  let subtree=false;
304
320
  try {
305
- if(fs.lstatSync(full).isSymbolicLink()&&fs.statSync(full).isDirectory()){
306
- this.unavailable=`directory symlink input unsupported: ${rel}`;return;}
307
- if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
321
+ // A link is an entry of its own: record() accepts an observed installed
322
+ // directory link and refuses any other.
323
+ if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
324
+ else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
308
325
  else this.record(rel);
309
326
  } catch(e) {
310
327
  if(e.code==='ENOENT') {
@@ -84,6 +84,10 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
84
84
  if(!resolved.physical){
85
85
  if(resolved.reason==='resolved target missing'&&!resolved.links.length){
86
86
  internal.add(pattern);continue;}
87
+ // An exclusion of a path that does not exist yet, through links (a runner
88
+ // cache inside a linked package): exclude it where the links lead.
89
+ if(negative&&resolved.reason==='resolved target missing'&&resolved.prospective&&within(resolved.prospective,root)){
90
+ internal.add('!'+posix(path.relative(root,resolved.prospective))+(suffix?'/'+suffix:''));continue;}
87
91
  result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
88
92
  }
89
93
  const boundary=within(resolved.physical,root)?root: