@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.
- package/CHANGELOG.md +97 -0
- package/README.md +1 -1
- package/bin/redue.mjs +25 -14
- package/docs/user/agents.md +11 -0
- package/docs/user/configuration.md +4 -0
- package/docs/user/direct-typescript.md +62 -5
- package/docs/user/pilot.md +6 -5
- package/docs/user/supported-projects.md +29 -7
- package/docs/user/tests-direct.md +28 -4
- package/package.json +2 -2
- package/src/agent-integration.mjs +2 -2
- package/src/claude-hook.mjs +100 -27
- package/src/cli.mjs +9 -4
- package/src/daemon.mjs +21 -7
- package/src/declared-plan.mjs +11 -4
- package/src/direct-typescript-probe.mjs +5 -3
- package/src/direct-typescript.mjs +58 -11
- package/src/glob.mjs +8 -0
- package/src/index.mjs +29 -12
- package/src/installed-inputs.mjs +4 -0
- package/src/node-onboarding.mjs +90 -47
- package/src/npm-integrity.mjs +124 -0
- package/src/npmrc.mjs +16 -0
- package/src/plan-worker.mjs +4 -1
- package/src/pnpm-inputs.mjs +111 -52
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/test-direct.mjs +198 -36
- package/src/test-recorder.cjs +3 -2
- package/src/typescript-input-facts.mjs +7 -7
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +2 -2
- package/src/typescript-qualification.mjs +8 -9
- package/src/workspace-manifests.mjs +22 -0
package/src/claude-hook.mjs
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
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),
|
|
110
|
-
local.push(...scriptForms(pm,
|
|
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
|
|
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(!
|
|
172
|
-
|
|
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
|
|
203
|
-
row.dir===dir&&row.local.includes(parts.base))
|
|
204
|
-
|
|
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){
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
212
|
-
const run
|
|
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
|
-
|
|
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
|
-
|
|
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??
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
242
|
-
'direct-typescript-version-unsupported'
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/declared-plan.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
|
64
|
-
if(!
|
|
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
|
-
//
|
|
106
|
-
export function directBuildInfo(root,
|
|
107
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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();
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
if(fs.
|
|
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') {
|
package/src/installed-inputs.mjs
CHANGED
|
@@ -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:
|