@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +831 -0
- package/README.md +157 -99
- package/bin/redue.mjs +322 -66
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +50 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +326 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +659 -0
- package/src/setup.mjs +224 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/history-worker.mjs
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
// Native history is an optional acceleration. Keep its operations off the
|
|
2
2
|
// control thread so a wedged native call cannot block status or shutdown.
|
|
3
|
+
// Each helper serves one query; the observer starts it ahead of that query.
|
|
3
4
|
import fs from 'node:fs';
|
|
5
|
+
import {mark} from './decision-profile.mjs';
|
|
6
|
+
mark('history.worker_loaded');
|
|
4
7
|
import watcher from '@parcel/watcher';
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
9
|
+
process.on('disconnect',()=>process.exit(0));
|
|
10
|
+
process.once('message',async({root,previous,next,backend,faultFile})=>{
|
|
11
|
+
try {
|
|
12
|
+
let fault;
|
|
13
|
+
try {fault=faultFile&&fs.readFileSync(faultFile,'utf8').trim();}
|
|
14
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
15
|
+
if(fault==='hang')Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
|
|
16
|
+
if(fault==='fail')throw Error('injected history failure');
|
|
17
|
+
mark('history.snapshot_start');
|
|
18
|
+
await watcher.writeSnapshot(root,next,{backend});
|
|
19
|
+
mark('history.snapshot_complete');
|
|
20
|
+
const changes=previous?await watcher.getEventsSince(root,previous,{backend}):[];
|
|
21
|
+
mark('history.events_complete',{events:changes.length});
|
|
22
|
+
process.send({ok:true,changes});
|
|
23
|
+
} catch(error) {
|
|
24
|
+
process.send({ok:false,error:error.message,code:error.code||error.name});
|
|
25
|
+
}
|
|
26
|
+
});
|
package/src/index.mjs
CHANGED
|
@@ -5,12 +5,18 @@ import {createHash} from 'node:crypto';
|
|
|
5
5
|
import micromatch from './glob.mjs';
|
|
6
6
|
import {sha} from './plan.mjs';
|
|
7
7
|
import {resolveLinks} from './installed-inputs.mjs';
|
|
8
|
-
import {validInputRelative} from './path-identity.mjs';
|
|
8
|
+
import {validInputRelative,identityFields,racyUntil} from './path-identity.mjs';
|
|
9
|
+
import {isSandboxed} from './sandbox.mjs';
|
|
9
10
|
|
|
10
11
|
const tracked = root => [...new Set(execFileSync('git',
|
|
11
12
|
['ls-files','--cached','--others','--exclude-standard','-z'],
|
|
12
13
|
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
13
14
|
.toString().split('\0').filter(Boolean))];
|
|
15
|
+
// Git-ignored paths at the top of each ignored tree: a directory ends in '/'.
|
|
16
|
+
const ignored = root => [...new Set(execFileSync('git',
|
|
17
|
+
['ls-files','--others','--ignored','--exclude-standard','--directory','-z'],
|
|
18
|
+
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
19
|
+
.toString().split('\0').filter(Boolean))];
|
|
14
20
|
const xor = (left,right) => { for(let i=0;i<32;i++) left[i]^=right[i]; };
|
|
15
21
|
const token = (file,hash) => createHash('sha256').update(file+'\0'+hash).digest();
|
|
16
22
|
const stablePrefix = glob => {
|
|
@@ -35,37 +41,52 @@ const pathPrefixes = rel => {
|
|
|
35
41
|
return prefixes;
|
|
36
42
|
};
|
|
37
43
|
|
|
44
|
+
// An installed file is observed by identity, not content: every write changes
|
|
45
|
+
// ctime, so its entry changes without reading the whole installation. The
|
|
46
|
+
// compiler's own reads are content-checked by its probe and certificate. A file
|
|
47
|
+
// changed within the racy window could still be rewritten in the same timestamp
|
|
48
|
+
// tick at the same size; such files are tracked in `racy`, and the daemon waits
|
|
49
|
+
// the window out and records them again before it writes a run checkpoint.
|
|
50
|
+
const identity=st=>identityFields.map(key=>String(st[key])).join(':');
|
|
51
|
+
// An installed link to a directory (pnpm links every package this way) is
|
|
52
|
+
// observed by its link chain alone, when `observedDir` says the directory it
|
|
53
|
+
// resolves to is itself observed installed: its own entries carry its contents.
|
|
38
54
|
function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
|
|
39
|
-
{bigint:true})) {
|
|
55
|
+
{bigint:true}),observedDir=null) {
|
|
40
56
|
const file=path.join(root, rel);
|
|
41
|
-
let value,references=[],bytes=Number(before.size);
|
|
57
|
+
let value,references=[],bytes=installed?0:Number(before.size),racy=0;
|
|
42
58
|
if(before.isSymbolicLink()){
|
|
43
59
|
const resolution=resolveLinks(file,root,[],true);
|
|
44
60
|
if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
|
|
45
61
|
const target=fs.statSync(resolution.physical,{bigint:true});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
|
|
63
|
+
const directory=target.isDirectory()&&installed&&observedDir?.(physical);
|
|
64
|
+
if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel} -> ${physical}`);
|
|
65
|
+
if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
|
|
66
|
+
else{
|
|
67
|
+
if(installed)racy=racyUntil(target);else bytes=Number(target.size);
|
|
68
|
+
value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
|
|
69
|
+
target.mode+':'+hashRegular(resolution.physical).hash);
|
|
70
|
+
}
|
|
50
71
|
references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
|
|
51
72
|
.split(path.sep).join('/')),path.relative(root,resolution.physical)
|
|
52
73
|
.split(path.sep).join('/')])];
|
|
53
|
-
|
|
54
|
-
const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
// A directory's own timestamps follow its entries, which are observed separately.
|
|
75
|
+
if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
76
|
+
for(const key of identityFields)
|
|
77
|
+
if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
|
|
57
78
|
const resolutionAfter=resolveLinks(file,root,[],true);
|
|
58
79
|
if(resolutionAfter.physical!==resolution.physical||
|
|
59
80
|
JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
|
|
60
81
|
throw Error(`input link changed during inspection: ${rel}`);
|
|
61
82
|
}
|
|
62
|
-
else if(before.isFile())
|
|
63
|
-
|
|
83
|
+
else if(before.isFile()&&installed){value='stat:'+identity(before);racy=racyUntil(before);}
|
|
84
|
+
else if(before.isFile()) value='file:'+before.mode+':'+hashRegular(file).hash;
|
|
64
85
|
else throw Error(`non-file input: ${rel}`);
|
|
65
86
|
const after=fs.lstatSync(file,{bigint:true});
|
|
66
|
-
for(const key of
|
|
87
|
+
for(const key of identityFields)
|
|
67
88
|
if(before[key]!==after[key]) throw Error(`input changed during inspection: ${rel}`);
|
|
68
|
-
return {hash:sha(value),references,bytes};
|
|
89
|
+
return {hash:sha(value),references,bytes,racy};
|
|
69
90
|
}
|
|
70
91
|
const readBuffer=Buffer.allocUnsafe(65536);
|
|
71
92
|
function hashRegular(file) {
|
|
@@ -81,6 +102,7 @@ export class InputIndex {
|
|
|
81
102
|
const matcherStarted=performance.now();
|
|
82
103
|
this.bundle=bundle; this.root=bundle.root; this.files=new Map(); this.members=new Map();
|
|
83
104
|
this.symlinkRefs=new Map();this.aliasDependencies=new Map();
|
|
105
|
+
this.racy=new Map();
|
|
84
106
|
this.trackGit=options.trackGit!==false;
|
|
85
107
|
this.installedRoots=[...new Set(Object.values(bundle.plans).flatMap(plan=>
|
|
86
108
|
plan.installedPhysicalRoots||[]))];
|
|
@@ -149,21 +171,46 @@ export class InputIndex {
|
|
|
149
171
|
}
|
|
150
172
|
return {files,bytes,durationMs:performance.now()-started};
|
|
151
173
|
}
|
|
174
|
+
// When the racy window of the named checks' installed members ends (0: none).
|
|
175
|
+
racyUntil(names=null) {
|
|
176
|
+
let until=0;
|
|
177
|
+
for(const [rel,time] of this.racy)
|
|
178
|
+
if(!names||[...this.members.get(rel)||[]].some(name=>names.includes(name)))until=Math.max(until,time);
|
|
179
|
+
return until;
|
|
180
|
+
}
|
|
181
|
+
// Records again every installed file whose racy window has passed.
|
|
182
|
+
settleRacy() {
|
|
183
|
+
for(const [rel,time] of [...this.racy])if(time<=Date.now())this.record(rel,rel+' changed');
|
|
184
|
+
}
|
|
152
185
|
summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
|
|
153
186
|
revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
|
|
154
|
-
|
|
187
|
+
// `only`, when given, limits which checks may take the file.
|
|
188
|
+
record(rel, changeReason=rel+' changed', observedStat=null, only=null) {
|
|
155
189
|
if(!validInputRelative(rel)) {
|
|
156
190
|
this.unavailable='invalid event path'; return false;
|
|
157
191
|
}
|
|
158
192
|
const names=this.candidates(rel), old=this.files.get(rel), matched=[];
|
|
159
|
-
for(const name of names) if(this.matches(this.checks.get(name),rel)) matched.push(name);
|
|
193
|
+
for(const name of names) if((!only||only(name))&&this.matches(this.checks.get(name),rel)) matched.push(name);
|
|
160
194
|
if(!matched.length && old===undefined) return false;
|
|
161
195
|
let fresh,stat,inspection;
|
|
162
196
|
try{stat=observedStat||fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
163
197
|
catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
|
|
164
198
|
if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
|
|
165
199
|
const installed=this.installedPath(rel);
|
|
166
|
-
try{const started=performance.now();
|
|
200
|
+
try{const started=performance.now();
|
|
201
|
+
// A linked directory counts as observed only strictly inside an
|
|
202
|
+
// installed root, or as a checkout directory outside node_modules (a
|
|
203
|
+
// workspace package the package manager links), and only if every
|
|
204
|
+
// check matching the link also observes the directory's own files
|
|
205
|
+
// (its package.json stands for them).
|
|
206
|
+
// The second form is for recorded checks only: their recorder
|
|
207
|
+
// classifies a read through the link where it leads.
|
|
208
|
+
const recorded=name=>['node-script-v1','npm-tests-direct-v1'].includes(this.checks.get(name).plan.qualification)||isSandboxed(this.checks.get(name).plan.qualification);
|
|
209
|
+
const observedDir=installed?physical=>!this.includeInstalled&&
|
|
210
|
+
([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
|
|
211
|
+
!physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
|
|
212
|
+
matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
|
|
213
|
+
inspection=fileHash(this.root,rel,installed,stat,observedDir);
|
|
167
214
|
fresh=inspection.hash;
|
|
168
215
|
this.profile.hashingMs+=performance.now()-started;
|
|
169
216
|
this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
|
|
@@ -182,6 +229,7 @@ export class InputIndex {
|
|
|
182
229
|
}
|
|
183
230
|
}
|
|
184
231
|
const oldNames=this.members.get(rel)||new Set(), newNames=new Set(fresh===undefined?[]:matched);
|
|
232
|
+
if(inspection?.racy>Date.now()&&newNames.size)this.racy.set(rel,inspection.racy);else this.racy.delete(rel);
|
|
185
233
|
if(old===fresh && oldNames.size===newNames.size && [...oldNames].every(n=>newNames.has(n))) return false;
|
|
186
234
|
for(const name of new Set([...oldNames,...newNames])) {
|
|
187
235
|
const row=this.checks.get(name);
|
|
@@ -226,6 +274,10 @@ export class InputIndex {
|
|
|
226
274
|
this.unavailable=null;
|
|
227
275
|
const trackedStarted=performance.now();
|
|
228
276
|
const names=new Set(this.trackGit?tracked(this.root):[]);
|
|
277
|
+
// A sandboxed check's reads are not recorded, so git-ignored files are its
|
|
278
|
+
// inputs too: every ignored file its patterns take, outside the scan roots
|
|
279
|
+
// (installed trees) and the directories no pattern can take a file from.
|
|
280
|
+
const ignoredTrees=this.trackGit&&[...this.checks.values()].some(row=>row.plan.ignoredInputs)?ignored(this.root):[];
|
|
229
281
|
this.profile.trackedMs+=performance.now()-trackedStarted;
|
|
230
282
|
const enumerationStarted=performance.now();
|
|
231
283
|
// Traverse declared input roots to include ignored/generated inputs. Avoid unrelated
|
|
@@ -269,6 +321,29 @@ export class InputIndex {
|
|
|
269
321
|
}
|
|
270
322
|
visit(base,prefix);
|
|
271
323
|
}
|
|
324
|
+
// Only the checks that take ignored files get them here, as before for the others.
|
|
325
|
+
const ignoring=name=>this.checks.get(name).plan.ignoredInputs===true;
|
|
326
|
+
const takes=rel=>[...this.checks.values()].some(row=>row.plan.ignoredInputs&&this.matches(row,rel));
|
|
327
|
+
// A node_modules that is not installed input is skipped, as above. An
|
|
328
|
+
// ignored link is taken when it leads to a file in the checkout; a link
|
|
329
|
+
// to a directory or out of the checkout is not a file input.
|
|
330
|
+
const linkToFile=rel=>{try{const target=fs.realpathSync(path.join(this.root,rel));
|
|
331
|
+
return fs.statSync(target).isFile()&&target.startsWith(fs.realpathSync(this.root)+path.sep);}catch{return false;}};
|
|
332
|
+
const visitIgnored=rel=>{
|
|
333
|
+
const base=path.posix.basename(rel);
|
|
334
|
+
if(this.prefixes.has(rel)||this.installedPath(rel)&&!this.installedAncestors.has(rel)||base==='.git'||
|
|
335
|
+
base==='node_modules'&&!this.installedPath(rel))return;
|
|
336
|
+
let st;try{st=fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
337
|
+
catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
338
|
+
this.profile.enumeratedEntries++;
|
|
339
|
+
if(st.isSymbolicLink()&&!linkToFile(rel))return;
|
|
340
|
+
if(st.isDirectory()){
|
|
341
|
+
if(!takes(rel+'/.redue-probe'))return;
|
|
342
|
+
for(const ent of fs.readdirSync(path.join(this.root,rel),{withFileTypes:true}))visitIgnored(rel+'/'+ent.name);
|
|
343
|
+
}else if(this.candidates(rel).size){
|
|
344
|
+
const started=performance.now();this.record(rel,'cold reconciliation',st,ignoring);processingMs+=performance.now()-started;scanned++;}
|
|
345
|
+
};
|
|
346
|
+
for(const entry of ignoredTrees)visitIgnored(entry.replace(/\/$/,''));
|
|
272
347
|
this.profile.enumerationMs+=performance.now()-enumerationStarted-processingMs;
|
|
273
348
|
const recordStarted=performance.now();
|
|
274
349
|
for(const rel of names) {
|
|
@@ -283,9 +358,10 @@ export class InputIndex {
|
|
|
283
358
|
const full=path.join(this.root,rel);
|
|
284
359
|
let subtree=false;
|
|
285
360
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if(fs.
|
|
361
|
+
// A link is an entry of its own: record() accepts an observed installed
|
|
362
|
+
// directory link and refuses any other.
|
|
363
|
+
if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
|
|
364
|
+
else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
|
|
289
365
|
else this.record(rel);
|
|
290
366
|
} catch(e) {
|
|
291
367
|
if(e.code==='ENOENT') {
|
|
@@ -304,3 +380,14 @@ export class InputIndex {
|
|
|
304
380
|
return out;
|
|
305
381
|
}
|
|
306
382
|
}
|
|
383
|
+
|
|
384
|
+
// The index state a worker sends and a restart retains; hydrate() in daemon.mjs
|
|
385
|
+
// reads it back.
|
|
386
|
+
export function dehydrateIndex(value) {
|
|
387
|
+
return {files:[...value.files],
|
|
388
|
+
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
389
|
+
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
390
|
+
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
391
|
+
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
392
|
+
rehashedBytes:value.rehashedBytes,profile:value.profile,racy:[...value.racy]};
|
|
393
|
+
}
|
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:
|
|
@@ -99,7 +103,9 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
|
|
|
99
103
|
else {if(!external.has(boundary))external.set(boundary,new Set());
|
|
100
104
|
external.get(boundary).add(mapped);}
|
|
101
105
|
if(!negative)roots.add(resolved.physical);
|
|
102
|
-
|
|
106
|
+
// An exclusion maps to the same pattern whether or not its path exists, so
|
|
107
|
+
// the plan stays the same when a run creates it (a runner cache).
|
|
108
|
+
if(!negative)result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
|
|
103
109
|
links:resolved.links.map(link=>link.path)});
|
|
104
110
|
}
|
|
105
111
|
result.internalPatterns=[...internal].sort();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Whether a check observes an installed file: the file is in the checkout
|
|
2
|
+
// and matched by the check's patterns, and a link it goes through that leads
|
|
3
|
+
// elsewhere in the checkout leads to a matched path too. A recorded run's
|
|
4
|
+
// hard-linked installed files are accepted only when every link is one the
|
|
5
|
+
// check observes, so a pattern that leaves a folder out (a runner cache in
|
|
6
|
+
// node_modules) cannot hide one.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {observedRelative} from './path-identity.mjs';
|
|
10
|
+
|
|
11
|
+
// `matches(rel)`: the check's pattern matcher over checkout-relative paths.
|
|
12
|
+
// A link that leads outside the checkout is the plan's to resolve (a
|
|
13
|
+
// permitted root or a refusal).
|
|
14
|
+
export function observedBy(root,matches){
|
|
15
|
+
let realRoot=root;try{realRoot=fs.realpathSync(root);}catch{}
|
|
16
|
+
return file=>{
|
|
17
|
+
const logical=observedRelative(root,path.resolve(file));
|
|
18
|
+
if(logical===null||!matches(logical))return false;
|
|
19
|
+
let real;try{real=fs.realpathSync(file);}catch{return false;}
|
|
20
|
+
const physical=observedRelative(realRoot,real);
|
|
21
|
+
return physical===null||physical===logical||matches(physical);
|
|
22
|
+
};
|
|
23
|
+
}
|