@redue/cli 0.0.0-stage → 0.1.0-alpha.3
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 +34 -0
- package/LICENSE +180 -0
- package/README.md +126 -2
- package/bin/redue.mjs +329 -0
- package/bin/vstate.mjs +3 -0
- package/docs/README.md +20 -0
- package/docs/user/agents.md +32 -0
- package/docs/user/configuration.md +57 -0
- package/docs/user/getting-started.md +127 -0
- package/docs/user/json.md +59 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/docs/user/shell.md +25 -0
- package/docs/user/supported-projects.md +89 -0
- package/examples/redue.config.json +8 -0
- package/package.json +46 -4
- package/src/cached-state.mjs +42 -0
- package/src/cli.mjs +401 -0
- package/src/control-endpoint.mjs +11 -0
- package/src/daemon.mjs +1004 -0
- package/src/decision-profile.mjs +18 -0
- package/src/decision-validation.mjs +41 -0
- package/src/declared-plan.mjs +125 -0
- package/src/executable-lookup.mjs +18 -0
- package/src/glob.mjs +9 -0
- package/src/history-worker.mjs +18 -0
- package/src/index.mjs +306 -0
- package/src/installed-inputs.mjs +110 -0
- package/src/linux-inotify.py +228 -0
- package/src/linux-observer.mjs +79 -0
- package/src/node-onboarding.mjs +263 -0
- package/src/npm-typecheck-probe.mjs +120 -0
- package/src/observation.mjs +37 -0
- package/src/owned-state.mjs +32 -0
- package/src/path-identity.mjs +43 -0
- package/src/plan-worker.mjs +131 -0
- package/src/plan.mjs +10 -0
- package/src/platform-observation.mjs +17 -0
- package/src/platform-state.mjs +14 -0
- package/src/pnpm-inputs.mjs +355 -0
- package/src/pnpm-typecheck-probe.mjs +47 -0
- package/src/presentation.mjs +52 -0
- package/src/probe-result.mjs +21 -0
- package/src/process-liveness.mjs +11 -0
- package/src/run-lock.mjs +77 -0
- package/src/state-store.mjs +73 -0
- package/src/toolchain-probe.mjs +21 -0
- package/src/typescript-contract-probe.mjs +188 -0
- package/src/typescript-list.mjs +68 -0
- package/src/typescript-qualification.mjs +96 -0
- package/src/windows-command.mjs +16 -0
- package/src/windows-process.mjs +15 -0
- package/src/yarn-workspace-inputs.mjs +148 -0
- package/src/yarn-workspace-typecheck-probe.mjs +107 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Opt-in, bounded local diagnostics. Never include paths, command arguments,
|
|
2
|
+
// environment values, source, or probe output in a timing record.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
const file=process.env.REDUE_DECISION_PROFILE_FILE;
|
|
6
|
+
export const profiling=process.env.REDUE_DECISION_PROFILE==='1'&&!!file;
|
|
7
|
+
|
|
8
|
+
export function timing(phase,started,counts={}) {
|
|
9
|
+
if(!profiling)return;
|
|
10
|
+
const memory=process.memoryUsage();
|
|
11
|
+
try{fs.appendFileSync(file,'REDUE_TIMING '+JSON.stringify({
|
|
12
|
+
at:Date.now(),pid:process.pid,phase,
|
|
13
|
+
ms:Math.round((performance.now()-started)*10)/10,
|
|
14
|
+
rssMiB:Math.round(memory.rss/1048576*10)/10,
|
|
15
|
+
...counts
|
|
16
|
+
})+'\n');}
|
|
17
|
+
catch{/* Diagnostics must never change probe or verification behavior. */}
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Deterministic reuse keys for already-resolved plans. These are not receipts
|
|
2
|
+
// or a second content cache: the existing InputIndex still reads current bytes
|
|
3
|
+
// and membership. A changed key falls back to provider discovery.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {execFileSync} from 'node:child_process';
|
|
7
|
+
import micromatch from './glob.mjs';
|
|
8
|
+
import {sha} from './plan.mjs';
|
|
9
|
+
|
|
10
|
+
export function inputKey(row) {
|
|
11
|
+
return sha(JSON.stringify([row.plan.id,row.files.size,row.sum.toString('hex')]));
|
|
12
|
+
}
|
|
13
|
+
export function planGuard(bundle) {
|
|
14
|
+
const root=bundle.root;
|
|
15
|
+
const paths=new Set(['package.json','package-lock.json','.npmrc','.yarnrc.yml',
|
|
16
|
+
'pnpm-workspace.yaml','.pnpmfile.cjs','.pnpmfile.mjs',
|
|
17
|
+
...bundle.workspaceManifests||[],...bundle.configurationInputs||[],
|
|
18
|
+
...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[])]);
|
|
19
|
+
const patterns=bundle.workspacePatterns?.length?bundle.workspacePatterns:
|
|
20
|
+
(()=>{const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json')));
|
|
21
|
+
return Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages||[];})();
|
|
22
|
+
if(patterns.length){
|
|
23
|
+
const files=execFileSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
24
|
+
'-z','--',':(glob)**/package.json'],{cwd:root,
|
|
25
|
+
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},timeout:5000,maxBuffer:32*1024*1024})
|
|
26
|
+
.toString().split('\0').filter(Boolean);
|
|
27
|
+
for(const file of files)if(micromatch.isMatch(path.posix.dirname(file),patterns,{dot:true}))
|
|
28
|
+
paths.add(file);
|
|
29
|
+
}
|
|
30
|
+
const values=[...paths].sort().map(rel=>{
|
|
31
|
+
const file=path.isAbsolute(rel)?rel:path.join(root,rel);
|
|
32
|
+
try{const st=fs.lstatSync(file,{bigint:true});
|
|
33
|
+
if(st.isSymbolicLink())return [rel,'link',fs.readlinkSync(file)];
|
|
34
|
+
if(st.isDirectory())return [rel,'directory',String(st.dev),String(st.ino)];
|
|
35
|
+
if(!st.isFile())throw Error('unobservable plan dependency');
|
|
36
|
+
return [rel,sha(fs.readFileSync(file))];
|
|
37
|
+
}catch(e){if(e.code==='ENOENT')return [rel,'missing'];throw e;}
|
|
38
|
+
});
|
|
39
|
+
const rootStat=fs.statSync(root,{bigint:true});
|
|
40
|
+
return sha(JSON.stringify([String(rootStat.dev),String(rootStat.ino),values]));
|
|
41
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A single-project plan. Declarations are explicit;
|
|
2
|
+
// this provider does not infer complete coverage from a successful command.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
6
|
+
import {permittedRoots,resolveDeclaredInstalled} from './installed-inputs.mjs';
|
|
7
|
+
import {qualifyTypeScript} from './typescript-qualification.mjs';
|
|
8
|
+
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
9
|
+
import {pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
10
|
+
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
11
|
+
import {timing} from './decision-profile.mjs';
|
|
12
|
+
|
|
13
|
+
const identity=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
14
|
+
const safePattern=value=>{
|
|
15
|
+
if(typeof value!=='string'||!value)throw Error('input pattern must be nonempty');
|
|
16
|
+
const raw=value.startsWith('!')?value.slice(1):value;
|
|
17
|
+
if(!raw||path.isAbsolute(raw)||raw.split(/[\\/]/).includes('..'))
|
|
18
|
+
throw Error(`unsafe repository-relative input pattern: ${value}`);
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function discoverDeclared(config) {
|
|
23
|
+
const root=realObservedPath(config.root),plans={},configurationInputs=new Set(),
|
|
24
|
+
workspaceManifests=new Set(),workspacePatterns=new Set();
|
|
25
|
+
if(!Array.isArray(config.checks)||!config.checks.length)throw Error('selected checks required');
|
|
26
|
+
for(const selected of config.checks){
|
|
27
|
+
if(typeof selected.name!=='string'||!selected.name||plans[selected.name])
|
|
28
|
+
throw Error('check names must be unique nonempty strings');
|
|
29
|
+
if(!Array.isArray(selected.command)||!selected.command.length||
|
|
30
|
+
!selected.command.every(item=>typeof item==='string'&&item))
|
|
31
|
+
throw Error(`exact command required for ${selected.name}`);
|
|
32
|
+
const cwd=path.resolve(root,selected.cwd||'.');
|
|
33
|
+
if(!withinPath(cwd,root))throw Error('check cwd escapes checkout');
|
|
34
|
+
for(const key of ['inputs','generatedInputs','installedInputs']){
|
|
35
|
+
if(!Array.isArray(selected[key]||[]))throw Error(`${key} must be a path-pattern array`);
|
|
36
|
+
for(const value of selected[key]||[])safePattern(value);
|
|
37
|
+
}
|
|
38
|
+
if(!selected.inputs?.some(value=>!value.startsWith('!')))
|
|
39
|
+
throw Error(`positive source inputs required for ${selected.name}`);
|
|
40
|
+
for(const argv of selected.probes||[])if(!Array.isArray(argv)||!argv.length||
|
|
41
|
+
!argv.every(value=>typeof value==='string'&&value))
|
|
42
|
+
throw Error('state probes must be exact nonempty argv arrays');
|
|
43
|
+
if(!Array.isArray(selected.environment?.variables||[])||
|
|
44
|
+
!(selected.environment?.variables||[]).every(value=>typeof value==='string'&&value))
|
|
45
|
+
throw Error('environment variables must be a string array');
|
|
46
|
+
if(!Array.isArray(selected.environment?.prefixes||[])||
|
|
47
|
+
!(selected.environment?.prefixes||[]).every(value=>
|
|
48
|
+
typeof value==='string'&&/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)))
|
|
49
|
+
throw Error('environment prefixes must be simple nonempty names');
|
|
50
|
+
if(!Array.isArray(selected.environment?.pathExecutables||[])||
|
|
51
|
+
!(selected.environment?.pathExecutables||[]).every(value=>
|
|
52
|
+
typeof value==='string'&&/^[A-Za-z_][A-Za-z0-9_-]*$/.test(value)))
|
|
53
|
+
throw Error('PATH executables must be simple command names');
|
|
54
|
+
const allowedRoots=permittedRoots(root,selected.allowedExternalRoots||[]);
|
|
55
|
+
let workspace=null,workspaceIssue=null;
|
|
56
|
+
const workspaceStarted=performance.now();
|
|
57
|
+
if(selected.qualification==='yarn-workspace-tsc-v1'){
|
|
58
|
+
try{workspace=yarnWorkspaceTypecheckInputs(root,selected.workspace,
|
|
59
|
+
selected.command[4]);}
|
|
60
|
+
catch(e){workspaceIssue=e.message;}
|
|
61
|
+
}
|
|
62
|
+
if(selected.qualification==='pnpm-tsc-v1'){
|
|
63
|
+
try{workspace=pnpmTypecheckInputs(root,selected.workspace||'.',
|
|
64
|
+
selected.script||selected.command.at(-1));}
|
|
65
|
+
catch(e){workspaceIssue=e.message;}
|
|
66
|
+
}
|
|
67
|
+
timing('plan.workspace_inputs',workspaceStarted,{available:workspace?1:0});
|
|
68
|
+
const installedStarted=performance.now();
|
|
69
|
+
const installed=resolveDeclaredInstalled(root,
|
|
70
|
+
[...(selected.installedInputs||[]),...(workspace?.installed||[])],allowedRoots);
|
|
71
|
+
timing('plan.installed_resolution',installedStarted,{mappings:installed.mappings.length});
|
|
72
|
+
const automatic=qualifyTypeScript(root,selected,
|
|
73
|
+
{workspace,issue:workspaceIssue});
|
|
74
|
+
const sourcePatterns=[...new Set([...selected.inputs,...(workspace?.source||[]),
|
|
75
|
+
...(workspace?.generated||[])])].sort();
|
|
76
|
+
const additivePatterns=[...new Set([...(selected.generatedInputs||[]),
|
|
77
|
+
...installed.internalPatterns])].sort();
|
|
78
|
+
const unresolved=[...installed.unresolved,...(automatic?.issues||[]),
|
|
79
|
+
...(workspace?.limitations||[]),
|
|
80
|
+
...(workspaceIssue?[workspaceIssue]:[])];
|
|
81
|
+
for(const item of workspace?.configurationInputs||[])configurationInputs.add(item);
|
|
82
|
+
for(const item of workspace?.workspacePatterns||[])workspacePatterns.add(item);
|
|
83
|
+
for(const item of workspace?.closure||[])workspaceManifests.add(item.manifest);
|
|
84
|
+
if(!automatic)for(const category of ['source','generated','installedDependencies',
|
|
85
|
+
'environment','toolchain','runtime'])if(!selected.coverage?.[category])
|
|
86
|
+
unresolved.push(selected.coverageReasons?.[category]||
|
|
87
|
+
`${category} coverage not reviewed for this command`);
|
|
88
|
+
if((selected.probes||[]).length&&!automatic&&!selected.coverage?.probeContinuity)
|
|
89
|
+
unresolved.push('probe state continuity during execution not established');
|
|
90
|
+
const installedPhysicalRoots=[...new Set(installed.physicalRoots
|
|
91
|
+
.filter(file=>withinPath(file,root)&&observedRelative(root,file)!=='')
|
|
92
|
+
.map(file=>observedRelative(root,file)))].sort();
|
|
93
|
+
const externalPatterns=installed.externalPatterns;
|
|
94
|
+
const plan={name:selected.name,provider:'declared-project@1',inputResolutionVersion:1,
|
|
95
|
+
root,cwd,selectedConfigHash:identity(selected),command:selected.command,
|
|
96
|
+
target:selected.name,taskCommand:null,taskGraph:[],
|
|
97
|
+
patterns:[...new Set([...sourcePatterns,...additivePatterns])].sort(),
|
|
98
|
+
sourcePatterns,additivePatterns,generated:[],
|
|
99
|
+
declaredGeneratedInputs:[...new Set([...(selected.generatedInputs||[]),
|
|
100
|
+
...(workspace?.generated||[])])].sort(),
|
|
101
|
+
declaredInstalledInputs:[...new Set([...(selected.installedInputs||[]),
|
|
102
|
+
...(workspace?.installed||[])])].sort(),
|
|
103
|
+
installation:workspace?.installation||null,
|
|
104
|
+
...(workspace?.synchronizedInstalledRead?{synchronizedInstalledRead:true}:{}),
|
|
105
|
+
installedInstances:workspace?.installedInstances||[],
|
|
106
|
+
resolutionCandidates:[],resolutionFindings:workspace?.absences||[],
|
|
107
|
+
installedPhysicalRoots,externalPatterns,
|
|
108
|
+
externalObservationRoots:Object.keys(externalPatterns).sort(),
|
|
109
|
+
resolutionLinks:installed.links,
|
|
110
|
+
resolutionTriggers:[...new Set([...installed.triggers,
|
|
111
|
+
...(workspace?.linkTriggers||[]),
|
|
112
|
+
...(workspace?.resolutionCandidates||[]).map(row=>row.path)])],
|
|
113
|
+
installedMappings:installed.mappings,effectiveEnvironmentGlobs:{},
|
|
114
|
+
probes:selected.probes||[],environment:selected.environment||{},
|
|
115
|
+
qualification:selected.qualification||null,
|
|
116
|
+
executionProvenance:'declared-command',
|
|
117
|
+
unresolved:[...new Set(unresolved)].sort()};
|
|
118
|
+
plan.resolutionCandidates=workspace?.resolutionCandidates||[];
|
|
119
|
+
plan.id=identity(plan);plans[selected.name]=plan;
|
|
120
|
+
}
|
|
121
|
+
return {schema:1,provider:'declared-project@1',root,plans,discoveredAt:Date.now(),
|
|
122
|
+
workspaceCount:1,workspacePatterns:[...workspacePatterns],
|
|
123
|
+
configurationInputs:[...configurationInputs].sort(),
|
|
124
|
+
workspaceManifests:[...workspaceManifests].sort()};
|
|
125
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Resolve the same project-local PATH that will be used for execution. Windows
|
|
5
|
+
// command shims have no extension in package scripts but normally end in .cmd.
|
|
6
|
+
export function findExecutable(name,{platform=process.platform,env=process.env,
|
|
7
|
+
cwd=process.cwd(),paths=path,access=fs.accessSync,realpath=fs.realpathSync}={}) {
|
|
8
|
+
if(!name||paths.basename(name)!==name)return null;
|
|
9
|
+
// An extensionless POSIX shell shim can coexist with npm.cmd on Windows.
|
|
10
|
+
// CreateProcess cannot run the shell shim, so prefer native PATHEXT files.
|
|
11
|
+
const suffixes=platform==='win32'&&!paths.extname(name)?
|
|
12
|
+
[...(env.PATHEXT||'.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean),'']:[''];
|
|
13
|
+
for(const folder of (env.PATH||'').split(paths.delimiter))for(const suffix of suffixes) {
|
|
14
|
+
const candidate=paths.join(folder||cwd,name+suffix);
|
|
15
|
+
try{access(candidate,fs.constants.X_OK);return realpath(candidate);}catch{}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
package/src/glob.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import picomatch from 'picomatch';
|
|
2
|
+
|
|
3
|
+
// These are the exact delegates used by micromatch 4.0.8 matcher/isMatch,
|
|
4
|
+
// with the same pinned picomatch 2.3.2. REDUE does not use brace expansion;
|
|
5
|
+
// importing the smaller dependency avoids shipping its unused vulnerable parser.
|
|
6
|
+
export default {
|
|
7
|
+
matcher(pattern,options){return picomatch(pattern,options);},
|
|
8
|
+
isMatch(file,patterns,options){return picomatch(patterns,options)(file);}
|
|
9
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Native history is an optional acceleration. Keep its operations off the
|
|
2
|
+
// control thread so a wedged native call cannot block status or shutdown.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import watcher from '@parcel/watcher';
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const [root,previous,next,backend,faultFile]=process.argv.slice(2);
|
|
8
|
+
let fault;
|
|
9
|
+
try {fault=faultFile&&fs.readFileSync(faultFile,'utf8').trim();}
|
|
10
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
11
|
+
if(fault==='hang')Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
|
|
12
|
+
if(fault==='fail')throw Error('injected history failure');
|
|
13
|
+
await watcher.writeSnapshot(root,next,{backend});
|
|
14
|
+
const changes=previous?await watcher.getEventsSince(root,previous,{backend}):[];
|
|
15
|
+
process.send({ok:true,changes});
|
|
16
|
+
} catch(error) {
|
|
17
|
+
process.send({ok:false,error:error.message,code:error.code||error.name});
|
|
18
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {execFileSync} from 'node:child_process';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import micromatch from './glob.mjs';
|
|
6
|
+
import {sha} from './plan.mjs';
|
|
7
|
+
import {resolveLinks} from './installed-inputs.mjs';
|
|
8
|
+
import {validInputRelative} from './path-identity.mjs';
|
|
9
|
+
|
|
10
|
+
const tracked = root => [...new Set(execFileSync('git',
|
|
11
|
+
['ls-files','--cached','--others','--exclude-standard','-z'],
|
|
12
|
+
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
13
|
+
.toString().split('\0').filter(Boolean))];
|
|
14
|
+
const xor = (left,right) => { for(let i=0;i<32;i++) left[i]^=right[i]; };
|
|
15
|
+
const token = (file,hash) => createHash('sha256').update(file+'\0'+hash).digest();
|
|
16
|
+
const stablePrefix = glob => {
|
|
17
|
+
const parts=glob.replace(/^!/, '').split('/'), fixed=[];
|
|
18
|
+
for(const p of parts) { if(/[?*[{(]/.test(p)) break; fixed.push(p); }
|
|
19
|
+
// An exact file is its own scan root. Scanning its parent could enumerate an
|
|
20
|
+
// entire ignored installation tree for one declared metadata file.
|
|
21
|
+
return fixed.join('/');
|
|
22
|
+
};
|
|
23
|
+
const groupMatchers = patterns => {
|
|
24
|
+
const groups=new Map();
|
|
25
|
+
for(const pattern of patterns) {
|
|
26
|
+
const prefix=stablePrefix(pattern);
|
|
27
|
+
if(!groups.has(prefix))groups.set(prefix,[]);
|
|
28
|
+
groups.get(prefix).push(micromatch.matcher(pattern,{dot:true}));
|
|
29
|
+
}
|
|
30
|
+
return groups;
|
|
31
|
+
};
|
|
32
|
+
const pathPrefixes = rel => {
|
|
33
|
+
const parts=rel.split('/'), prefixes=[''];
|
|
34
|
+
for(let i=1;i<=parts.length;i++)prefixes.push(parts.slice(0,i).join('/'));
|
|
35
|
+
return prefixes;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
|
|
39
|
+
{bigint:true})) {
|
|
40
|
+
const file=path.join(root, rel);
|
|
41
|
+
let value,references=[],bytes=Number(before.size);
|
|
42
|
+
if(before.isSymbolicLink()){
|
|
43
|
+
const resolution=resolveLinks(file,root,[],true);
|
|
44
|
+
if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
|
|
45
|
+
const target=fs.statSync(resolution.physical,{bigint:true});
|
|
46
|
+
if(!target.isFile())throw Error(`directory input symlink unsupported: ${rel}`);
|
|
47
|
+
value='link:'+JSON.stringify(resolution.links)+':'+target.mode+':'+
|
|
48
|
+
(installed&&target.nlink>1?target.nlink+':':'')+
|
|
49
|
+
hashRegular(resolution.physical).hash;
|
|
50
|
+
references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
|
|
51
|
+
.split(path.sep).join('/')),path.relative(root,resolution.physical)
|
|
52
|
+
.split(path.sep).join('/')])];
|
|
53
|
+
bytes=Number(target.size);
|
|
54
|
+
const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
55
|
+
for(const key of ['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'])
|
|
56
|
+
if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);
|
|
57
|
+
const resolutionAfter=resolveLinks(file,root,[],true);
|
|
58
|
+
if(resolutionAfter.physical!==resolution.physical||
|
|
59
|
+
JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
|
|
60
|
+
throw Error(`input link changed during inspection: ${rel}`);
|
|
61
|
+
}
|
|
62
|
+
else if(before.isFile()) value='file:'+before.mode+':'+
|
|
63
|
+
(installed&&before.nlink>1?before.nlink+':':'')+hashRegular(file).hash;
|
|
64
|
+
else throw Error(`non-file input: ${rel}`);
|
|
65
|
+
const after=fs.lstatSync(file,{bigint:true});
|
|
66
|
+
for(const key of ['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'])
|
|
67
|
+
if(before[key]!==after[key]) throw Error(`input changed during inspection: ${rel}`);
|
|
68
|
+
return {hash:sha(value),references,bytes};
|
|
69
|
+
}
|
|
70
|
+
const readBuffer=Buffer.allocUnsafe(65536);
|
|
71
|
+
function hashRegular(file) {
|
|
72
|
+
const h=createHash('sha256'), fd=fs.openSync(file,'r');
|
|
73
|
+
try { let n; while((n=fs.readSync(fd,readBuffer,0,readBuffer.length,null))>0)
|
|
74
|
+
h.update(readBuffer.subarray(0,n)); }
|
|
75
|
+
finally { fs.closeSync(fd); }
|
|
76
|
+
return {hash:h.digest('hex')};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class InputIndex {
|
|
80
|
+
constructor(bundle, options={}) {
|
|
81
|
+
const matcherStarted=performance.now();
|
|
82
|
+
this.bundle=bundle; this.root=bundle.root; this.files=new Map(); this.members=new Map();
|
|
83
|
+
this.symlinkRefs=new Map();this.aliasDependencies=new Map();
|
|
84
|
+
this.trackGit=options.trackGit!==false;
|
|
85
|
+
this.installedRoots=[...new Set(Object.values(bundle.plans).flatMap(plan=>
|
|
86
|
+
plan.installedPhysicalRoots||[]))];
|
|
87
|
+
this.installedRootSet=new Set(this.installedRoots);
|
|
88
|
+
this.installedAncestors=new Set(this.installedRoots.flatMap(pathPrefixes));
|
|
89
|
+
this.includeInstalled=options.includeInstalled||false;
|
|
90
|
+
this.checks=new Map(); this.prefixes=new Map(); this.eventCount=0; this.rehashedFiles=0;
|
|
91
|
+
this.rehashedBytes=0; this.lastChange=0; this.unavailable=null;
|
|
92
|
+
this.profile={matcherMs:0,trackedMs:0,enumerationMs:0,matchingMs:0,
|
|
93
|
+
hashingMs:0,recordMs:0,enumeratedEntries:0};
|
|
94
|
+
for(const [name,plan] of Object.entries(bundle.plans)) {
|
|
95
|
+
const source=plan.sourcePatterns||plan.patterns;
|
|
96
|
+
const positive=source.filter(p=>!p.startsWith('!'));
|
|
97
|
+
const negative=source.filter(p=>p.startsWith('!')).map(p=>p.slice(1));
|
|
98
|
+
const additivePositive=(plan.additivePatterns||[]).filter(p=>!p.startsWith('!'));
|
|
99
|
+
const additiveNegative=(plan.additivePatterns||[]).filter(p=>p.startsWith('!'))
|
|
100
|
+
.map(p=>p.slice(1));
|
|
101
|
+
const row={plan,positive:groupMatchers(positive),negative:groupMatchers(negative),
|
|
102
|
+
additivePositive:groupMatchers(additivePositive),
|
|
103
|
+
additiveNegative:groupMatchers(additiveNegative),
|
|
104
|
+
files:new Set(),sum:Buffer.alloc(32),revision:0,lastReason:null,probeHash:null,probeAt:0};
|
|
105
|
+
this.checks.set(name,row);
|
|
106
|
+
for(const p of [...positive,...additivePositive]) {
|
|
107
|
+
const prefix=stablePrefix(p);
|
|
108
|
+
if(!this.prefixes.has(prefix)) this.prefixes.set(prefix,new Set());
|
|
109
|
+
this.prefixes.get(prefix).add(name);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.profile.matcherMs=performance.now()-matcherStarted;
|
|
113
|
+
}
|
|
114
|
+
candidates(rel) {
|
|
115
|
+
const names=new Set(this.prefixes.get('')||[]);
|
|
116
|
+
const parts=rel.split('/');
|
|
117
|
+
for(let i=1;i<=parts.length;i++)
|
|
118
|
+
for(const name of this.prefixes.get(parts.slice(0,i).join('/'))||[]) names.add(name);
|
|
119
|
+
return names;
|
|
120
|
+
}
|
|
121
|
+
matches(row,rel) { const started=performance.now(), prefixes=pathPrefixes(rel);
|
|
122
|
+
const any=(groups)=>prefixes.some(prefix=>(groups.get(prefix)||[]).some(fn=>fn(rel)));
|
|
123
|
+
const result=(any(row.positive) && !any(row.negative))||
|
|
124
|
+
(any(row.additivePositive)&&!any(row.additiveNegative));
|
|
125
|
+
this.profile.matchingMs+=performance.now()-started;return result; }
|
|
126
|
+
installedPath(rel) {
|
|
127
|
+
if(this.includeInstalled||this.installedAncestors.has(rel))return true;
|
|
128
|
+
for(let slash=rel.length;slash>0;slash=rel.lastIndexOf('/',slash-1)){
|
|
129
|
+
if(this.installedRootSet.has(rel.slice(0,slash)))return true;
|
|
130
|
+
if(slash===0)break;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
fingerprint(name) {
|
|
135
|
+
const row=this.checks.get(name);
|
|
136
|
+
return sha(row.plan.id+'\0'+row.files.size+'\0'+row.sum.toString('hex')+'\0'+(row.probeHash||''));
|
|
137
|
+
}
|
|
138
|
+
async recheckInstalled(names) {
|
|
139
|
+
const selected=new Set(names),started=performance.now();
|
|
140
|
+
let files=0,bytes=0;
|
|
141
|
+
for(const rel of [...this.files.keys()]){
|
|
142
|
+
if(!this.installedPath(rel)||![...this.members.get(rel)||[]]
|
|
143
|
+
.some(name=>selected.has(name)))continue;
|
|
144
|
+
const before=this.rehashedBytes;
|
|
145
|
+
this.record(rel,rel+' changed');
|
|
146
|
+
if(this.unavailable)throw Error(this.unavailable);
|
|
147
|
+
files++;bytes+=this.rehashedBytes-before;
|
|
148
|
+
if(files%32===0)await new Promise(resolve=>setImmediate(resolve));
|
|
149
|
+
}
|
|
150
|
+
return {files,bytes,durationMs:performance.now()-started};
|
|
151
|
+
}
|
|
152
|
+
summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
|
|
153
|
+
revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
|
|
154
|
+
record(rel, changeReason=rel+' changed', observedStat=null) {
|
|
155
|
+
if(!validInputRelative(rel)) {
|
|
156
|
+
this.unavailable='invalid event path'; return false;
|
|
157
|
+
}
|
|
158
|
+
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);
|
|
160
|
+
if(!matched.length && old===undefined) return false;
|
|
161
|
+
let fresh,stat,inspection;
|
|
162
|
+
try{stat=observedStat||fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
163
|
+
catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
|
|
164
|
+
if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
|
|
165
|
+
const installed=this.installedPath(rel);
|
|
166
|
+
try{const started=performance.now();inspection=fileHash(this.root,rel,installed,stat);
|
|
167
|
+
fresh=inspection.hash;
|
|
168
|
+
this.profile.hashingMs+=performance.now()-started;
|
|
169
|
+
this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
|
|
170
|
+
catch(e){this.unavailable=String(e);return false;}
|
|
171
|
+
} else if(stat){this.unavailable=`non-file input: ${rel}`;return false;}
|
|
172
|
+
for(const reference of this.aliasDependencies.get(rel)||[]){
|
|
173
|
+
const aliases=this.symlinkRefs.get(reference);
|
|
174
|
+
aliases?.delete(rel);if(aliases?.size===0)this.symlinkRefs.delete(reference);
|
|
175
|
+
}
|
|
176
|
+
this.aliasDependencies.delete(rel);
|
|
177
|
+
if(inspection?.references.length){
|
|
178
|
+
this.aliasDependencies.set(rel,new Set(inspection.references));
|
|
179
|
+
for(const reference of inspection.references){
|
|
180
|
+
if(!this.symlinkRefs.has(reference))this.symlinkRefs.set(reference,new Set());
|
|
181
|
+
this.symlinkRefs.get(reference).add(rel);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const oldNames=this.members.get(rel)||new Set(), newNames=new Set(fresh===undefined?[]:matched);
|
|
185
|
+
if(old===fresh && oldNames.size===newNames.size && [...oldNames].every(n=>newNames.has(n))) return false;
|
|
186
|
+
for(const name of new Set([...oldNames,...newNames])) {
|
|
187
|
+
const row=this.checks.get(name);
|
|
188
|
+
if(oldNames.has(name) && old!==undefined) {xor(row.sum,token(rel,old));row.files.delete(rel);}
|
|
189
|
+
if(newNames.has(name) && fresh!==undefined) {xor(row.sum,token(rel,fresh));row.files.add(rel);}
|
|
190
|
+
row.revision++;row.lastReason=changeReason;
|
|
191
|
+
}
|
|
192
|
+
if(fresh!==undefined && newNames.size) {this.files.set(rel,fresh);this.members.set(rel,newNames);}
|
|
193
|
+
else {this.files.delete(rel);this.members.delete(rel);}
|
|
194
|
+
if(old!==fresh || oldNames.size!==newNames.size) {
|
|
195
|
+
this.eventCount++;this.lastChange=Date.now();return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
scanSubtree(rel) {
|
|
200
|
+
const found=new Set(), roots=new Set();
|
|
201
|
+
// Directory notifications can name only an ancestor of an exact input.
|
|
202
|
+
// Revisit matching declared roots without traversing unrelated siblings.
|
|
203
|
+
if([...this.prefixes.keys()].some(prefix=>!prefix||prefix===rel||rel.startsWith(prefix+'/')))
|
|
204
|
+
roots.add(rel);
|
|
205
|
+
for(const prefix of this.prefixes.keys())
|
|
206
|
+
if(!rel||prefix.startsWith(rel+'/'))roots.add(prefix);
|
|
207
|
+
const visit=(file,r)=>{
|
|
208
|
+
let st;try{st=fs.lstatSync(file);}catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
209
|
+
if(st.isDirectory()) {
|
|
210
|
+
if(r && (path.posix.basename(r)==='.git'||
|
|
211
|
+
(path.posix.basename(r)==='node_modules'&&!this.installedPath(r)))&&
|
|
212
|
+
!this.prefixes.has(r)) return;
|
|
213
|
+
for(const entry of fs.readdirSync(file,{withFileTypes:true}))
|
|
214
|
+
visit(path.join(file,entry.name),r?r+'/'+entry.name:entry.name);
|
|
215
|
+
} else if(!found.has(r)) {found.add(r);this.record(r);}
|
|
216
|
+
};
|
|
217
|
+
for(const root of roots) {
|
|
218
|
+
const base=path.join(this.root,root);
|
|
219
|
+
let present=true;
|
|
220
|
+
try{fs.lstatSync(base);}catch(e){if(e.code==='ENOENT')present=false;else throw e;}
|
|
221
|
+
if(present)visit(base,root);
|
|
222
|
+
}
|
|
223
|
+
for(const old of [...this.files.keys()]) if((old===rel||old.startsWith(rel+'/'))&&!found.has(old)) this.record(old);
|
|
224
|
+
}
|
|
225
|
+
coldScan(progress) {
|
|
226
|
+
this.unavailable=null;
|
|
227
|
+
const trackedStarted=performance.now();
|
|
228
|
+
const names=new Set(this.trackGit?tracked(this.root):[]);
|
|
229
|
+
this.profile.trackedMs+=performance.now()-trackedStarted;
|
|
230
|
+
const enumerationStarted=performance.now();
|
|
231
|
+
// Traverse declared input roots to include ignored/generated inputs. Avoid unrelated
|
|
232
|
+
// node_modules/.git trees unless a pattern explicitly names them.
|
|
233
|
+
const roots=[...this.prefixes.keys()].filter(prefix=>prefix||!this.trackGit)
|
|
234
|
+
.sort((a,b)=>a.length-b.length);
|
|
235
|
+
const selected=[], selectedSet=new Set();
|
|
236
|
+
for(const prefix of roots) {
|
|
237
|
+
const parts=prefix.split('/');
|
|
238
|
+
if(parts.some((_,i)=>selectedSet.has(parts.slice(0,i+1).join('/'))))continue;
|
|
239
|
+
selected.push(prefix);selectedSet.add(prefix);
|
|
240
|
+
}
|
|
241
|
+
let scanned=0,processingMs=0;
|
|
242
|
+
for(const prefix of selected) {
|
|
243
|
+
const base=path.join(this.root,prefix);
|
|
244
|
+
const visit=(file,rel)=>{
|
|
245
|
+
this.profile.enumeratedEntries++;
|
|
246
|
+
let st;try{st=fs.lstatSync(file,{bigint:true});}
|
|
247
|
+
catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
248
|
+
if(st.isDirectory()) {
|
|
249
|
+
if((path.posix.basename(rel)==='.git'||
|
|
250
|
+
(path.posix.basename(rel)==='node_modules'&&!this.installedPath(rel))) &&
|
|
251
|
+
!this.prefixes.has(rel))return;
|
|
252
|
+
for(const ent of fs.readdirSync(file,{withFileTypes:true}))
|
|
253
|
+
visit(path.join(file,ent.name),rel?rel+'/'+ent.name:ent.name);
|
|
254
|
+
} else {
|
|
255
|
+
names.delete(rel);
|
|
256
|
+
const started=performance.now();
|
|
257
|
+
if(this.candidates(rel).size)this.record(rel,'cold reconciliation',st);
|
|
258
|
+
processingMs+=performance.now()-started;
|
|
259
|
+
scanned++;
|
|
260
|
+
if(progress&&scanned%256===0)progress({scanned,total:null});
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
let st;try{st=fs.lstatSync(base,{bigint:true});}
|
|
264
|
+
catch(e){if(e.code==='ENOENT')continue;throw e;}
|
|
265
|
+
if(st.isSymbolicLink()) {
|
|
266
|
+
try{if(fs.statSync(base).isDirectory()){
|
|
267
|
+
this.unavailable=`glob rooted at directory symlink unsupported: ${prefix}`;continue;}}
|
|
268
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
269
|
+
}
|
|
270
|
+
visit(base,prefix);
|
|
271
|
+
}
|
|
272
|
+
this.profile.enumerationMs+=performance.now()-enumerationStarted-processingMs;
|
|
273
|
+
const recordStarted=performance.now();
|
|
274
|
+
for(const rel of names) {
|
|
275
|
+
if(this.candidates(rel).size)this.record(rel,'cold reconciliation');
|
|
276
|
+
scanned++;
|
|
277
|
+
if(progress&&scanned%256===0)progress({scanned,total:null});
|
|
278
|
+
}
|
|
279
|
+
if(progress)progress({scanned,total:scanned});
|
|
280
|
+
this.profile.recordMs+=processingMs+performance.now()-recordStarted;
|
|
281
|
+
}
|
|
282
|
+
updatePath(rel) {
|
|
283
|
+
const full=path.join(this.root,rel);
|
|
284
|
+
let subtree=false;
|
|
285
|
+
try {
|
|
286
|
+
if(fs.lstatSync(full).isSymbolicLink()&&fs.statSync(full).isDirectory()){
|
|
287
|
+
this.unavailable=`directory symlink input unsupported: ${rel}`;return;}
|
|
288
|
+
if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
|
|
289
|
+
else this.record(rel);
|
|
290
|
+
} catch(e) {
|
|
291
|
+
if(e.code==='ENOENT') {
|
|
292
|
+
if([...this.files.keys()].some(p=>p.startsWith(rel+'/'))) {
|
|
293
|
+
this.scanSubtree(rel);subtree=true;}
|
|
294
|
+
else this.record(rel);
|
|
295
|
+
} else {this.unavailable=String(e);}
|
|
296
|
+
}
|
|
297
|
+
const aliases=new Set(this.symlinkRefs.get(rel)||[]);
|
|
298
|
+
if(subtree)for(const [reference,names] of this.symlinkRefs)
|
|
299
|
+
if(reference.startsWith(rel+'/'))for(const name of names)aliases.add(name);
|
|
300
|
+
for(const alias of aliases)if(alias!==rel)this.record(alias,`${rel} changed`);
|
|
301
|
+
}
|
|
302
|
+
exportFiles(name) { const row=this.checks.get(name), out={};
|
|
303
|
+
for(const file of row.files) out[file]=this.files.get(file);
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Declared installed-input paths, link resolution, and permitted observation roots.
|
|
2
|
+
// Physical contents and membership are observed separately by InputIndex.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
import {normalizedPath,samePath,withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
8
|
+
|
|
9
|
+
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const posix=file=>file.split(path.sep).join('/');
|
|
11
|
+
export function resolveLinks(file,checkout,allowedRoots=[],allowFile=false) {
|
|
12
|
+
const roots=[checkout,...allowedRoots],links=[],seen=new Set();
|
|
13
|
+
let candidate=normalizedPath(file);
|
|
14
|
+
for(let depth=0;depth<40;depth++){
|
|
15
|
+
let current=path.parse(candidate).root,redirected=false;
|
|
16
|
+
const parts=candidate.slice(current.length).split(path.sep).filter(Boolean);
|
|
17
|
+
for(let i=0;i<parts.length;i++){
|
|
18
|
+
current=path.join(current,parts[i]);
|
|
19
|
+
let st;try{st=fs.lstatSync(current);}catch(e){
|
|
20
|
+
return {physical:null,links,prospective:candidate,
|
|
21
|
+
reason:e.code==='ENOENT'?'resolved target missing':
|
|
22
|
+
`resolution unavailable: ${e.code||e.name}`};}
|
|
23
|
+
if(!st.isSymbolicLink()){
|
|
24
|
+
// NTFS may spell an existing component as RUNNER~1 or its long name.
|
|
25
|
+
// Normalize that component without skipping a later reparse point.
|
|
26
|
+
if(process.platform==='win32')current=fs.realpathSync.native(current);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if(!roots.some(root=>within(current,root)))
|
|
30
|
+
return {physical:null,links,reason:'link redirect outside observed roots'};
|
|
31
|
+
if([...seen].some(previous=>samePath(previous,current)))
|
|
32
|
+
return {physical:null,links,reason:'symlink cycle'};
|
|
33
|
+
seen.add(current);
|
|
34
|
+
const target=fs.readlinkSync(current),signature=hash(target);
|
|
35
|
+
links.push({path:current,targetHash:signature});
|
|
36
|
+
candidate=normalizedPath(path.resolve(path.dirname(current),target,...parts.slice(i+1)));
|
|
37
|
+
redirected=true;break;
|
|
38
|
+
}
|
|
39
|
+
if(redirected)continue;
|
|
40
|
+
candidate=current;
|
|
41
|
+
if(!roots.some(root=>within(candidate,root)))
|
|
42
|
+
return {physical:null,links,reason:'resolved target outside permitted roots'};
|
|
43
|
+
let st;try{st=fs.statSync(candidate);}catch(e){
|
|
44
|
+
return {physical:null,links,reason:e.code==='ENOENT'?'resolved target missing':
|
|
45
|
+
`resolution unavailable: ${e.code||e.name}`};}
|
|
46
|
+
if(!st.isDirectory()&&!(allowFile&&st.isFile()))
|
|
47
|
+
return {physical:null,links,reason:'resolved input has unsupported type'};
|
|
48
|
+
return {physical:candidate,links,reason:null};
|
|
49
|
+
}
|
|
50
|
+
return {physical:null,links,reason:'symlink resolution depth exceeded'};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function permittedRoots(root,values=[]) {
|
|
54
|
+
const allowed=[];
|
|
55
|
+
for(const value of values) {
|
|
56
|
+
if(typeof value!=='string'||!path.isAbsolute(value))
|
|
57
|
+
throw Error('external observation root must be absolute');
|
|
58
|
+
const real=realObservedPath(value);
|
|
59
|
+
if([path.parse(real).root,os.homedir()].includes(real)||within(root,real))
|
|
60
|
+
throw Error('external observation root is too broad');
|
|
61
|
+
if(!within(real,root))allowed.push(real);
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(allowed)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
|
|
67
|
+
const result={internalPatterns:[],externalPatterns:{},links:[],triggers:[],
|
|
68
|
+
unresolved:[],mappings:[],physicalRoots:[]};
|
|
69
|
+
const internal=new Set(),external=new Map(),links=new Map(),triggers=new Set(),roots=new Set();
|
|
70
|
+
for(const pattern of patterns||[]) {
|
|
71
|
+
const negative=pattern.startsWith('!'),raw=negative?pattern.slice(1):pattern;
|
|
72
|
+
// Exclusions must also apply at the logical link location. The mapped
|
|
73
|
+
// physical exclusion alone cannot stop a broad installed-tree scan from
|
|
74
|
+
// trying to hash a workspace directory symlink as a file.
|
|
75
|
+
if(negative)internal.add(pattern);
|
|
76
|
+
const parts=raw.split('/'),fixed=[];
|
|
77
|
+
for(const part of parts){if(/[?*[{(]/.test(part))break;fixed.push(part);}
|
|
78
|
+
if(!fixed.length){internal.add(pattern);continue;}
|
|
79
|
+
const prefix=fixed.join('/'),suffix=parts.slice(fixed.length).join('/'),
|
|
80
|
+
logical=path.join(root,prefix);
|
|
81
|
+
const resolved=resolveLinks(logical,root,allowed,!suffix);
|
|
82
|
+
for(const link of resolved.links){links.set(link.path,link);
|
|
83
|
+
if(within(link.path,root))triggers.add(posix(path.relative(root,link.path)));}
|
|
84
|
+
if(!resolved.physical){
|
|
85
|
+
if(resolved.reason==='resolved target missing'&&!resolved.links.length){
|
|
86
|
+
internal.add(pattern);continue;}
|
|
87
|
+
result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
|
|
88
|
+
}
|
|
89
|
+
const boundary=within(resolved.physical,root)?root:
|
|
90
|
+
allowed.find(allowedRoot=>within(resolved.physical,allowedRoot));
|
|
91
|
+
if(!boundary){result.unresolved.push(`${prefix}: resolved target outside permitted roots`);continue;}
|
|
92
|
+
if(boundary!==root&&resolved.physical===boundary){
|
|
93
|
+
result.unresolved.push(`${prefix}: external target needs a stable permitted parent root`);continue;}
|
|
94
|
+
const rel=posix(path.relative(boundary,resolved.physical));
|
|
95
|
+
const directory=fs.statSync(resolved.physical).isDirectory();
|
|
96
|
+
const mapped=(negative?'!':'')+(suffix?(rel?rel+'/':'')+suffix:
|
|
97
|
+
directory?(rel?rel+'/**':'**/*'):rel);
|
|
98
|
+
if(boundary===root)internal.add(mapped);
|
|
99
|
+
else {if(!external.has(boundary))external.set(boundary,new Set());
|
|
100
|
+
external.get(boundary).add(mapped);}
|
|
101
|
+
if(!negative)roots.add(resolved.physical);
|
|
102
|
+
result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
|
|
103
|
+
links:resolved.links.map(link=>link.path)});
|
|
104
|
+
}
|
|
105
|
+
result.internalPatterns=[...internal].sort();
|
|
106
|
+
result.externalPatterns=Object.fromEntries([...external].map(([key,values])=>[key,[...values].sort()]));
|
|
107
|
+
result.links=[...links.values()].sort((a,b)=>a.path.localeCompare(b.path));
|
|
108
|
+
result.triggers=[...triggers].sort();result.physicalRoots=[...roots].sort();
|
|
109
|
+
return result;
|
|
110
|
+
}
|