@redue/cli 0.0.0-stage → 0.1.0-alpha.11
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 +181 -0
- package/LICENSE +180 -0
- package/README.md +162 -2
- package/bin/redue.mjs +371 -0
- package/bin/vstate.mjs +3 -0
- package/docs/README.md +21 -0
- package/docs/user/agents.md +253 -0
- package/docs/user/configuration.md +60 -0
- package/docs/user/direct-typescript.md +114 -0
- package/docs/user/getting-started.md +148 -0
- package/docs/user/json.md +59 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +145 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/docs/user/shell.md +25 -0
- package/docs/user/supported-projects.md +90 -0
- package/examples/redue.config.json +8 -0
- package/package.json +47 -4
- package/src/agent-integration.mjs +294 -0
- package/src/agent-policy.md +70 -0
- package/src/cached-state.mjs +42 -0
- package/src/claude-hook.mjs +115 -0
- package/src/cli.mjs +499 -0
- package/src/control-endpoint.mjs +11 -0
- package/src/daemon.mjs +1236 -0
- package/src/decision-profile.mjs +32 -0
- package/src/decision-validation.mjs +51 -0
- package/src/declared-plan.mjs +125 -0
- package/src/direct-run-certificate.mjs +185 -0
- package/src/direct-typescript-probe.mjs +22 -0
- package/src/direct-typescript.mjs +109 -0
- package/src/executable-lookup.mjs +18 -0
- package/src/glob.mjs +9 -0
- package/src/history-worker.mjs +26 -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 +274 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npm-typecheck-probe.mjs +120 -0
- package/src/observation.mjs +59 -0
- package/src/owned-state.mjs +32 -0
- package/src/path-identity.mjs +43 -0
- package/src/plan-worker.mjs +140 -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 +53 -0
- package/src/probe-result.mjs +25 -0
- package/src/process-liveness.mjs +11 -0
- package/src/project-location.mjs +34 -0
- package/src/run-lock.mjs +77 -0
- package/src/run-recorder.cjs +78 -0
- package/src/state-store.mjs +73 -0
- package/src/toolchain-probe.mjs +21 -0
- package/src/typescript-contract-probe.mjs +5 -0
- package/src/typescript-input-facts.mjs +68 -0
- package/src/typescript-list.mjs +141 -0
- package/src/typescript-qualification.mjs +77 -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,79 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const helper=path.join(path.dirname(fileURLToPath(import.meta.url)),'linux-inotify.py');
|
|
7
|
+
const fault=(reason,code)=>Object.assign(Error(reason),{code:code||reason});
|
|
8
|
+
|
|
9
|
+
export function subscribeLinux(root,callback) {
|
|
10
|
+
return new Promise((resolve,reject)=>{
|
|
11
|
+
const child=spawn('python3',['-u',helper,root],
|
|
12
|
+
{stdio:['pipe','pipe','ignore']});
|
|
13
|
+
let ready=false,closing=false,ended=false,buffer='',last=Date.now();
|
|
14
|
+
const barriers=new Map();
|
|
15
|
+
const startup=setTimeout(()=>fail(fault('Linux observer startup timed out')),10000);
|
|
16
|
+
const heartbeat=setInterval(()=>{
|
|
17
|
+
if(ready&&!closing&&!barriers.size&&Date.now()-last>4000)
|
|
18
|
+
fail(fault('Linux observer heartbeat unavailable'));
|
|
19
|
+
},1000);
|
|
20
|
+
const cleanup=()=>{clearTimeout(startup);clearInterval(heartbeat);
|
|
21
|
+
for(const [id,pending] of barriers){clearTimeout(pending.timer);
|
|
22
|
+
pending.reject(fault('Linux observer unavailable'));barriers.delete(id);}};
|
|
23
|
+
const fail=error=>{
|
|
24
|
+
if(ended)return;ended=true;cleanup();
|
|
25
|
+
if(!ready)reject(error);else if(!closing)callback(error,[]);
|
|
26
|
+
child.kill('SIGTERM');
|
|
27
|
+
};
|
|
28
|
+
const request=(kind,fields={},timeoutMs=5000)=>{
|
|
29
|
+
if(ended||closing)return Promise.reject(fault('Linux observer unavailable'));
|
|
30
|
+
return new Promise((done,no)=>{
|
|
31
|
+
const id=randomUUID();const timer=setTimeout(()=>{
|
|
32
|
+
barriers.delete(id);no(fault(`Linux observer ${kind} timed out`));
|
|
33
|
+
fail(fault(`Linux observer ${kind} timed out`));
|
|
34
|
+
},timeoutMs);
|
|
35
|
+
barriers.set(id,{resolve:done,reject:no,timer});
|
|
36
|
+
child.stdin.write(JSON.stringify({kind,id,...fields})+'\n');
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
child.on('error',fail);
|
|
40
|
+
child.stdin.on('error',fail);
|
|
41
|
+
child.stdout.on('error',fail);
|
|
42
|
+
child.on('exit',(code,signal)=>{
|
|
43
|
+
if(!closing&&!ended)fail(fault(`Linux observer exited: ${code??signal}`));
|
|
44
|
+
else cleanup();
|
|
45
|
+
});
|
|
46
|
+
child.stdout.on('data',data=>{
|
|
47
|
+
buffer+=data.toString();
|
|
48
|
+
if(buffer.length>1024*1024){fail(fault('Linux observer output backlog'));return;}
|
|
49
|
+
for(;;){const end=buffer.indexOf('\n');if(end<0)break;
|
|
50
|
+
const line=buffer.slice(0,end);buffer=buffer.slice(end+1);
|
|
51
|
+
let message;try{message=JSON.parse(line);}catch{fail(fault('Linux observer protocol error'));return;}
|
|
52
|
+
last=Date.now();
|
|
53
|
+
if(message.kind==='ready'){
|
|
54
|
+
ready=true;clearTimeout(startup);
|
|
55
|
+
resolve({
|
|
56
|
+
synchronize(timeoutMs=5000){return request('barrier',{},timeoutMs);},
|
|
57
|
+
watchFiles(paths,timeoutMs=30000){
|
|
58
|
+
return request('watch-files',{paths},timeoutMs);
|
|
59
|
+
},
|
|
60
|
+
async unsubscribe(){
|
|
61
|
+
if(closing)return;closing=true;cleanup();child.stdin.end();
|
|
62
|
+
if(ended||child.exitCode!==null||child.signalCode!==null)return;
|
|
63
|
+
const exit=new Promise(done=>child.once('exit',done));
|
|
64
|
+
child.kill('SIGTERM');
|
|
65
|
+
const killer=setTimeout(()=>child.kill('SIGKILL'),1000);
|
|
66
|
+
await exit;clearTimeout(killer);
|
|
67
|
+
},
|
|
68
|
+
get pid(){return child.pid;}
|
|
69
|
+
});
|
|
70
|
+
}else if(message.kind==='event')callback(null,[{type:message.type,path:message.path}]);
|
|
71
|
+
else if(message.kind==='gap'){fail(fault(message.reason,message.reason));return;}
|
|
72
|
+
else if(message.kind==='barrier'||message.kind==='watched'){
|
|
73
|
+
const pending=barriers.get(message.id);if(!pending)continue;
|
|
74
|
+
barriers.delete(message.id);clearTimeout(pending.timer);pending.resolve();
|
|
75
|
+
}else if(message.kind!=='heartbeat'){fail(fault('Linux observer protocol error'));return;}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {directProject,directQualification} from './direct-typescript.mjs';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {spawnSync} from 'node:child_process';
|
|
5
|
+
import micromatch from './glob.mjs';
|
|
6
|
+
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
7
|
+
import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
8
|
+
import {realObservedPath} from './path-identity.mjs';
|
|
9
|
+
import {findExecutable} from './executable-lookup.mjs';
|
|
10
|
+
|
|
11
|
+
const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
|
|
12
|
+
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
13
|
+
const scriptNames={
|
|
14
|
+
typecheck:['typecheck','type-check','check:types','types:check'],
|
|
15
|
+
test:['test','test:unit','test:check'],
|
|
16
|
+
lint:['lint','lint:check'],
|
|
17
|
+
build:['build'],
|
|
18
|
+
};
|
|
19
|
+
const sourceExtensions='**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}';
|
|
20
|
+
const likelyDirs=['src','app','apps','pages','lib','packages','test','tests','__tests__','scripts'];
|
|
21
|
+
const toolConfigs=['tsconfig.json','tsconfig.base.json','vite.config.ts','vite.config.js',
|
|
22
|
+
'vitest.config.ts','vitest.config.js','jest.config.js','jest.config.ts',
|
|
23
|
+
'eslint.config.js','eslint.config.mjs','.eslintrc','.eslintrc.json',
|
|
24
|
+
'.eslintrc.js','.npmrc','.yarnrc.yml'];
|
|
25
|
+
function installedNpmVersion() {
|
|
26
|
+
try{
|
|
27
|
+
const executable=findExecutable('npm');
|
|
28
|
+
if(!executable)return null;
|
|
29
|
+
const manifest=readJson(process.platform==='win32'?
|
|
30
|
+
path.join(path.dirname(executable),'node_modules','npm','package.json'):
|
|
31
|
+
path.join(path.dirname(path.dirname(executable)),'package.json'));
|
|
32
|
+
if(manifest.name==='npm')return manifest.version;
|
|
33
|
+
}catch{}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function manager(root,pkg) {
|
|
38
|
+
const declared=pkg.packageManager?.match(/^(npm|yarn|pnpm|bun)@([^+\s]+)/);
|
|
39
|
+
const locks=['package-lock.json','npm-shrinkwrap.json','yarn.lock','pnpm-lock.yaml',
|
|
40
|
+
'bun.lock','bun.lockb'].filter(name=>exists(path.join(root,name)));
|
|
41
|
+
const kinds=new Set(locks.map(name=>name.startsWith('package-lock')||name==='npm-shrinkwrap.json'?
|
|
42
|
+
'npm':name==='yarn.lock'?'yarn':name.startsWith('pnpm')?'pnpm':'bun'));
|
|
43
|
+
if(declared)kinds.add(declared[1]);
|
|
44
|
+
if(kinds.size!==1)return {name:null,version:declared?.[2]||null,layout:'unknown',
|
|
45
|
+
issue:kinds.size?'package-manager declaration conflicts with lockfiles':
|
|
46
|
+
'no supported lockfile or packageManager declaration'};
|
|
47
|
+
const name=[...kinds][0],version=declared?.[1]===name?declared[2]:null;
|
|
48
|
+
if(name==='bun')return {name,version,layout:'unsupported',
|
|
49
|
+
issue:'bun installation layout is not supported in this alpha'};
|
|
50
|
+
if(name==='pnpm'){
|
|
51
|
+
try{const installed=pnpmInstallInfo(root);
|
|
52
|
+
return {name,version:installed.version,
|
|
53
|
+
layout:`node-modules/${installed.linker}`,issue:null,lockfiles:locks,
|
|
54
|
+
workspacePatterns:installed.patterns};}
|
|
55
|
+
catch(e){let project;try{project=pnpmProjectInfo(root);}catch{}
|
|
56
|
+
return {name,version:project?.version||version,
|
|
57
|
+
layout:project?.linker||'recording-only',issue:e.message,
|
|
58
|
+
lockfiles:locks,workspacePatterns:project?.patterns||[]};}
|
|
59
|
+
}
|
|
60
|
+
if(name==='npm')return {name,version:version||installedNpmVersion(),
|
|
61
|
+
layout:'node-modules',issue:null,
|
|
62
|
+
lockfiles:locks};
|
|
63
|
+
const yarnrc=path.join(root,'.yarnrc.yml');
|
|
64
|
+
if(!exists(yarnrc)){
|
|
65
|
+
const classicLock=exists(path.join(root,'yarn.lock'))&&
|
|
66
|
+
/^# yarn lockfile v1\s*$/m.test(fs.readFileSync(path.join(root,'yarn.lock'),'utf8').slice(0,256));
|
|
67
|
+
const classic=version?.startsWith('1.')||(!version&&classicLock);
|
|
68
|
+
return {name,version:version||null,layout:classic?'node-modules':'unknown',
|
|
69
|
+
issue:!version?'Yarn version is not declared; Corepack may select a different version or edit package.json':
|
|
70
|
+
classic?null:'Yarn linker is not declared; node-modules layout cannot be assumed',
|
|
71
|
+
lockfiles:locks};
|
|
72
|
+
}
|
|
73
|
+
const text=fs.readFileSync(yarnrc,'utf8');
|
|
74
|
+
const linker=text.match(/^\s*nodeLinker\s*:\s*['"]?([\w-]+)/m)?.[1];
|
|
75
|
+
const mode=text.match(/^\s*nmMode\s*:\s*['"]?([\w-]+)/m)?.[1]||'classic';
|
|
76
|
+
if(linker!=='node-modules')return {name,version,layout:linker||'unknown',
|
|
77
|
+
issue:`Yarn ${linker||'unspecified'} linker is outside the node-modules alpha path`,
|
|
78
|
+
lockfiles:locks};
|
|
79
|
+
if(mode!=='classic')return {name,version,layout:`node-modules/${mode}`,
|
|
80
|
+
issue:`Yarn nmMode ${mode} is not yet observed safely`,lockfiles:locks};
|
|
81
|
+
return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
|
|
82
|
+
}
|
|
83
|
+
const exactTypeScript=script=>script.trim()==='tsc --noEmit';
|
|
84
|
+
function sourceLinkIssue(root) {
|
|
85
|
+
const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
|
|
86
|
+
let inspected=0;
|
|
87
|
+
while(stack.length){
|
|
88
|
+
const rel=stack.pop(),full=path.join(root,rel);
|
|
89
|
+
if(fs.lstatSync(full).isSymbolicLink())
|
|
90
|
+
return `${rel} is a source link; declare its permitted observation boundary explicitly`;
|
|
91
|
+
let entries;try{entries=fs.readdirSync(full,{withFileTypes:true});}
|
|
92
|
+
catch{return `${rel} cannot be inspected; source input coverage needs review`;}
|
|
93
|
+
for(const entry of entries){
|
|
94
|
+
if(++inspected>100000)return 'source tree is too large for init link inspection';
|
|
95
|
+
const child=`${rel}/${entry.name}`;
|
|
96
|
+
if(entry.isSymbolicLink())return `${child} is a source link; declare its permitted observation boundary explicitly`;
|
|
97
|
+
if(entry.isDirectory()&&!['.git','node_modules'].includes(entry.name))stack.push(child);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function sourceInputs(root,kind,lockfiles) {
|
|
103
|
+
const files=['package.json',...lockfiles,...toolConfigs.filter(name=>exists(path.join(root,name)))];
|
|
104
|
+
if(kind==='typecheck')return [...new Set(['package.json',...lockfiles,
|
|
105
|
+
'tsconfig.json',sourceExtensions,
|
|
106
|
+
'!node_modules/**','!.git/**'])];
|
|
107
|
+
const dirs=likelyDirs.filter(name=>exists(path.join(root,name)))
|
|
108
|
+
.map(name=>`${name}/**`);
|
|
109
|
+
return [...new Set([...files,...(dirs.length?dirs:['package.json'])])];
|
|
110
|
+
}
|
|
111
|
+
function classify(kind,scriptName,script,pkg,managerInfo,root) {
|
|
112
|
+
const value=script.trim();
|
|
113
|
+
if(managerInfo.issue)return {level:managerInfo.name==='pnpm'?'recording':'unsupported',
|
|
114
|
+
reason:managerInfo.issue};
|
|
115
|
+
if(kind==='typecheck'&&exactTypeScript(value)&&
|
|
116
|
+
!pkg.scripts?.['pre'+scriptName]&&!pkg.scripts?.['post'+scriptName]&&
|
|
117
|
+
managerInfo.name==='npm'&&
|
|
118
|
+
exists(path.join(root,'tsconfig.json'))&&
|
|
119
|
+
exists(path.join(root,'node_modules/typescript/package.json')))
|
|
120
|
+
return {level:'recording',reason:'npm launcher uses unobserved remote/cache/time inputs; execution can be recorded, but cannot be reused',
|
|
121
|
+
qualification:'typescript-noemit-v1'};
|
|
122
|
+
if(kind==='typecheck')return {level:'recording',reason:
|
|
123
|
+
'TypeScript invocation, installation, or configuration is outside the automatic noEmit contract'};
|
|
124
|
+
if(kind==='test')return {level:'recording',reason:/\b(vitest|jest)\b/.test(value)?
|
|
125
|
+
'test framework detected; caches, transforms and runtime inputs need review':
|
|
126
|
+
'test script detected; runtime and generated inputs need review'};
|
|
127
|
+
if(kind==='lint')return {level:'recording',reason:/\beslint\b/.test(value)?
|
|
128
|
+
'ESLint detected; plugin/config and resolver inputs need review':
|
|
129
|
+
'lint script detected; tool and config inputs need review'};
|
|
130
|
+
return {level:'recording',reason:/\bvite\b/.test(value)?
|
|
131
|
+
'Vite detected; build environment and generated inputs need review':
|
|
132
|
+
'build script detected; generated and runtime inputs need review'};
|
|
133
|
+
}
|
|
134
|
+
function selectScript(kind,names,scripts) {
|
|
135
|
+
const preferred=names.find(name=>typeof scripts[name]==='string'&&
|
|
136
|
+
scripts[name].trim()&&!/^echo\s+["']?Error: no test specified/.test(scripts[name]));
|
|
137
|
+
if(preferred)return {script:preferred};
|
|
138
|
+
const candidates=Object.keys(scripts).filter(name=>{
|
|
139
|
+
const value=scripts[name]?.trim();
|
|
140
|
+
if(typeof value!=='string')return false;
|
|
141
|
+
if(kind==='typecheck')return exactTypeScript(value);
|
|
142
|
+
if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
|
|
143
|
+
if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
|
|
144
|
+
return /^vite\s+build(?:\s|$)/.test(value);
|
|
145
|
+
});
|
|
146
|
+
return candidates.length===1?{script:candidates[0]}:
|
|
147
|
+
candidates.length>1?{ambiguous:candidates}:{};
|
|
148
|
+
}
|
|
149
|
+
function workspaceManifests(root,patterns) {
|
|
150
|
+
const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
151
|
+
'-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
|
|
152
|
+
maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
|
|
153
|
+
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
|
|
154
|
+
if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
|
|
155
|
+
const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
|
|
156
|
+
const dir=path.posix.dirname(file);
|
|
157
|
+
return dir!=='.'&&!dir.split('/').includes('node_modules')&&
|
|
158
|
+
micromatch.isMatch(dir,patterns,{dot:true});
|
|
159
|
+
}).sort();
|
|
160
|
+
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
161
|
+
return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
|
|
162
|
+
}
|
|
163
|
+
export function discoverNodeProject(root,{recipe=null}={}) {
|
|
164
|
+
if(recipe&&recipe!=='typescript-direct')throw Error('unknown recipe; use typescript-direct');
|
|
165
|
+
root=realObservedPath(root);
|
|
166
|
+
const packageFile=path.join(root,'package.json');
|
|
167
|
+
if(!exists(packageFile))throw Error('run init from a repository containing package.json');
|
|
168
|
+
const pkg=readJson(packageFile),pm=manager(root,pkg),scripts=pkg.scripts||{};
|
|
169
|
+
const git=spawnSync('git',['rev-parse','--show-toplevel'],{cwd:root,
|
|
170
|
+
encoding:'utf8',timeout:3000,stdio:['ignore','pipe','ignore']});
|
|
171
|
+
const repositoryIssue=git.status===0?null:
|
|
172
|
+
'Git checkout is unavailable; filesystem observation needs a checkout';
|
|
173
|
+
const observationIssue=repositoryIssue||sourceLinkIssue(root);
|
|
174
|
+
const workspacePatterns=pm.name==='pnpm'?pm.workspacePatterns:
|
|
175
|
+
Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
|
|
176
|
+
const workspaces=Boolean(pkg.workspaces)||Boolean(workspacePatterns?.length);
|
|
177
|
+
if(workspaces&&(!Array.isArray(workspacePatterns)||
|
|
178
|
+
workspacePatterns.some(value=>typeof value!=='string'||path.isAbsolute(value)||
|
|
179
|
+
value.split('/').includes('..'))))
|
|
180
|
+
throw Error('workspace patterns need a supported project-relative package list');
|
|
181
|
+
const checks=[],ambiguous=[];
|
|
182
|
+
for(const [kind,names] of Object.entries(scriptNames)) {
|
|
183
|
+
const selected=selectScript(kind,names,scripts),script=selected.script;
|
|
184
|
+
if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
|
|
185
|
+
if(!script)continue;
|
|
186
|
+
let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
|
|
187
|
+
classify(kind,script,scripts[script],pkg,pm,root);
|
|
188
|
+
if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
|
|
189
|
+
reason:'workspace task and installed-input closure need explicit qualification'};
|
|
190
|
+
const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
|
|
191
|
+
if(recipe==='typescript-direct'&&kind==='typecheck'){
|
|
192
|
+
try{
|
|
193
|
+
if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
|
|
194
|
+
const compiler=directProject(root,script);
|
|
195
|
+
check.command=['@node','@typescript-compiler:.','--noEmit'];check.cwd='.';
|
|
196
|
+
classification={level:'ready',qualification:directQualification,
|
|
197
|
+
reason:`explicit local TypeScript ${compiler.version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
|
|
198
|
+
}catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
|
|
199
|
+
}
|
|
200
|
+
if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
|
|
201
|
+
/^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(scripts[script].trim())){
|
|
202
|
+
try{const contract=pnpmTypecheckInputs(root,'.',script);
|
|
203
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
204
|
+
check.command=['@node','@typescript-bin:.',
|
|
205
|
+
...scripts[script].trim().split(/\s+/).slice(1)];
|
|
206
|
+
check.cwd='.';
|
|
207
|
+
check.inputs=['package.json','pnpm-lock.yaml',
|
|
208
|
+
...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
|
|
209
|
+
'tsconfig.json','src/**'];
|
|
210
|
+
classification={level:'ready',reason:'pinned pnpm TypeScript inputs and installation; checked again before reuse',
|
|
211
|
+
qualification:'pnpm-tsc-v1'};}
|
|
212
|
+
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
213
|
+
}
|
|
214
|
+
if(classification.qualification)check.qualification=classification.qualification;
|
|
215
|
+
checks.push({config:check,...classification});
|
|
216
|
+
}
|
|
217
|
+
if(workspaces&&!observationIssue){
|
|
218
|
+
const seen=new Set();
|
|
219
|
+
for(const {file,pkg:member} of workspaceManifests(root,workspacePatterns)){
|
|
220
|
+
const dir=path.posix.dirname(file),name=member.name;
|
|
221
|
+
if(typeof name!=='string'||!name||seen.has(name)){
|
|
222
|
+
ambiguous.push({kind:'workspace',scripts:[dir],reason:'missing or duplicate package name'});
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
seen.add(name);
|
|
226
|
+
if(!['npm','yarn','pnpm'].includes(pm.name))continue;
|
|
227
|
+
for(const [kind,names] of Object.entries(scriptNames)){
|
|
228
|
+
const selected=selectScript(kind,names,member.scripts||{});
|
|
229
|
+
if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
|
|
230
|
+
scripts:selected.ambiguous});continue;}
|
|
231
|
+
if(!selected.script)continue;
|
|
232
|
+
const command=pm.name==='npm'?['@which:npm','run',selected.script,
|
|
233
|
+
'--workspace',name]:pm.name==='yarn'?
|
|
234
|
+
['@which:yarn','workspace',name,'run',selected.script]:
|
|
235
|
+
['@which:pnpm','--filter',name,'run',selected.script];
|
|
236
|
+
const check={name:`${name}:${kind}`,kind,command,
|
|
237
|
+
inputs:['package.json',...(pm.lockfiles||[]),`${dir}/**`,
|
|
238
|
+
`!${dir}/node_modules/**`]};
|
|
239
|
+
let level=pm.issue&&pm.name!=='pnpm'?'unsupported':'recording';
|
|
240
|
+
let reason=pm.issue||'workspace script found; shared configuration and installed inputs need review';
|
|
241
|
+
if(!pm.issue&&pm.name==='yarn'&&pm.layout==='node-modules/classic'&&
|
|
242
|
+
kind==='typecheck'&&(member.scripts||{})[selected.script]?.trim()==='tsc -p .'&&
|
|
243
|
+
!member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
|
|
244
|
+
try{
|
|
245
|
+
yarnWorkspaceTypecheckInputs(root,name,selected.script);
|
|
246
|
+
check.workspace=name;check.qualification='yarn-workspace-tsc-v1';
|
|
247
|
+
check.inputs=['package.json','yarn.lock','.yarnrc.yml',file,`${dir}/src/**`];
|
|
248
|
+
level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
|
|
249
|
+
}catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
|
|
250
|
+
}
|
|
251
|
+
if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
|
|
252
|
+
/^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(
|
|
253
|
+
(member.scripts||{})[selected.script]?.trim()||'')&&
|
|
254
|
+
!member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
|
|
255
|
+
try{const contract=pnpmTypecheckInputs(root,name,selected.script);
|
|
256
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
257
|
+
check.workspace=name;check.qualification='pnpm-tsc-v1';
|
|
258
|
+
check.script=selected.script;
|
|
259
|
+
check.cwd=dir;
|
|
260
|
+
check.command=['@node',`@typescript-bin:${dir}`,
|
|
261
|
+
...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
|
|
262
|
+
check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
|
|
263
|
+
`${dir}/src/**`];
|
|
264
|
+
level='ready';reason='pinned pnpm workspace TypeScript inputs; checked again before reuse';
|
|
265
|
+
}catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
|
|
266
|
+
}
|
|
267
|
+
checks.push({config:check,level,reason});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {schema:1,root,manager:pm,observationIssue,workspaces,node:process.version,
|
|
272
|
+
checks,ambiguous,
|
|
273
|
+
config:{schema:1,packageManager:pm.name||'unknown',checks:checks.map(row=>row.config)}};
|
|
274
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Admission for the standalone npm 10.9.2 -> tsc --noEmit contract only.
|
|
2
|
+
// Values are never returned: the caller separately fingerprints every original
|
|
3
|
+
// npm_config_* key/value (including case and empty values).
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export const npmEnvironmentInterpretation = 'npm-tsc-environment@2';
|
|
6
|
+
const proxyKeys = new Set(['proxy', 'https_proxy', 'http_proxy', 'noproxy']);
|
|
7
|
+
export function npmTypecheckEnvironmentIssue(env, {npmVersion, platform = process.platform} = {}) {
|
|
8
|
+
if (env.NODE_OPTIONS || env.NODE_PATH || env.BASH_ENV || env.ENV)
|
|
9
|
+
return 'execution-environment-unsupported';
|
|
10
|
+
const seen = new Map();
|
|
11
|
+
for (const [key, value] of Object.entries(env)) {
|
|
12
|
+
if (/^(DYLD_|TSGO_)/i.test(key)) return 'execution-environment-unsupported';
|
|
13
|
+
if (!/^npm_config_/i.test(key)) continue;
|
|
14
|
+
if (/^npm_config_prefix$/i.test(key) && platform === 'win32' && path.win32.isAbsolute(value)) continue;
|
|
15
|
+
const suffix = key.slice('npm_config_'.length).toLowerCase();
|
|
16
|
+
if (!proxyKeys.has(suffix)) return 'execution-environment-unsupported';
|
|
17
|
+
// http_proxy is an unknown, inert pass-through key in this inspected npm
|
|
18
|
+
// version, not an alias for proxy. Other npm versions require source review.
|
|
19
|
+
if (npmVersion !== '10.9.2') return 'npm-proxy-version-unsupported';
|
|
20
|
+
if (seen.has(suffix) && seen.get(suffix) !== value)
|
|
21
|
+
return 'npm-proxy-context-ambiguous';
|
|
22
|
+
seen.set(suffix, value);
|
|
23
|
+
if (typeof value !== 'string' || /[\x00-\x1f\x7f]/.test(value))
|
|
24
|
+
return 'npm-proxy-value-unsupported';
|
|
25
|
+
// npm ignores empty environment config, but passes the original environment
|
|
26
|
+
// to the child. Empty and absent remain distinct caller identities.
|
|
27
|
+
if (value === '' || (suffix === 'proxy' && value === 'false')) continue;
|
|
28
|
+
if (suffix === 'noproxy') continue;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value);
|
|
31
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname)
|
|
32
|
+
return 'npm-proxy-value-unsupported';
|
|
33
|
+
} catch { return 'npm-proxy-value-unsupported'; }
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Read-only reviewed boundary for an npm script invoking local TypeScript.
|
|
3
|
+
// A nonzero exit withdraws applicability. Output is a digest, never config values.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
import {spawnSync} from 'node:child_process';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
|
|
10
|
+
const [rootArg,nodeArg,npmArg,reviewedTsconfigHash,npmVersion,typescriptVersion]=process.argv.slice(2);
|
|
11
|
+
const sha=value=>createHash('sha256').update(value).digest('hex');
|
|
12
|
+
const inside=(file,dir)=>file===dir||file.startsWith(dir+path.sep);
|
|
13
|
+
const fail=reason=>{throw Error(`typecheck boundary unavailable: ${reason}`);};
|
|
14
|
+
const real=file=>fs.realpathSync(file);
|
|
15
|
+
function fileHash(file){
|
|
16
|
+
const digest=createHash('sha256'),fd=fs.openSync(file,'r'),buffer=Buffer.alloc(1024*1024);
|
|
17
|
+
try{for(;;){const count=fs.readSync(fd,buffer,0,buffer.length,null);
|
|
18
|
+
if(!count)break;digest.update(buffer.subarray(0,count));}}
|
|
19
|
+
finally{fs.closeSync(fd);}
|
|
20
|
+
return digest.digest('hex');
|
|
21
|
+
}
|
|
22
|
+
function treeHash(dir){
|
|
23
|
+
const digest=createHash('sha256');
|
|
24
|
+
function visit(folder,relative=''){
|
|
25
|
+
for(const entry of fs.readdirSync(folder,{withFileTypes:true}).sort((a,b)=>
|
|
26
|
+
a.name.localeCompare(b.name))){
|
|
27
|
+
const rel=relative?relative+'/'+entry.name:entry.name,full=path.join(folder,entry.name);
|
|
28
|
+
if(entry.isDirectory()){digest.update(`d\0${rel}\0`);visit(full,rel);}
|
|
29
|
+
else if(entry.isFile())digest.update(`f\0${rel}\0${fileHash(full)}\0`);
|
|
30
|
+
else fail('npm installation contains an unsupported entry');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
visit(dir);return digest.digest('hex');
|
|
34
|
+
}
|
|
35
|
+
function namesAt(dir){
|
|
36
|
+
if(!fs.existsSync(dir))return null;
|
|
37
|
+
if(!fs.statSync(dir).isDirectory())fail('ancestor resolution boundary is not a directory');
|
|
38
|
+
const names=[];
|
|
39
|
+
for(const entry of fs.readdirSync(dir,{withFileTypes:true}).sort((a,b)=>
|
|
40
|
+
a.name.localeCompare(b.name))){
|
|
41
|
+
names.push(entry.name);
|
|
42
|
+
if(entry.name.startsWith('@')&&entry.isDirectory())
|
|
43
|
+
names.push(...fs.readdirSync(path.join(dir,entry.name)).map(name=>entry.name+'/'+name).sort());
|
|
44
|
+
}
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
function rcKeys(file){
|
|
48
|
+
if(!fs.existsSync(file))return null;
|
|
49
|
+
const keys=[];
|
|
50
|
+
for(const line of fs.readFileSync(file,'utf8').split(/\r?\n/)){
|
|
51
|
+
const trimmed=line.trim();
|
|
52
|
+
if(!trimmed||trimmed.startsWith('#')||trimmed.startsWith(';'))continue;
|
|
53
|
+
const at=trimmed.indexOf('=');
|
|
54
|
+
if(at<0)fail('npm configuration has an unsupported entry');
|
|
55
|
+
const key=trimmed.slice(0,at).trim();
|
|
56
|
+
// These affect package acquisition, not this offline `npm run typecheck`.
|
|
57
|
+
if(key!=='registry'&&!/^\/\/[^\s]+\/:_authToken$/.test(key))
|
|
58
|
+
fail('npm configuration contains an unreviewed key');
|
|
59
|
+
keys.push(key);
|
|
60
|
+
}
|
|
61
|
+
return keys.sort();
|
|
62
|
+
}
|
|
63
|
+
function pathExecutable(name){
|
|
64
|
+
for(const folder of (process.env.PATH||'').split(path.delimiter)){
|
|
65
|
+
if(!folder)continue;
|
|
66
|
+
const candidate=path.join(folder,name);
|
|
67
|
+
try{fs.accessSync(candidate,fs.constants.X_OK);return real(candidate);}catch{}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try{
|
|
72
|
+
if(!rootArg||!nodeArg||!npmArg||!reviewedTsconfigHash||!npmVersion||!typescriptVersion)
|
|
73
|
+
fail('arguments missing');
|
|
74
|
+
const root=real(rootArg),node=real(nodeArg),npm=real(npmArg),home=process.env.HOME;
|
|
75
|
+
if(!home||!path.isAbsolute(home))fail('HOME is unavailable');
|
|
76
|
+
const homeTarget=real(home);
|
|
77
|
+
if(real(process.execPath)!==node||pathExecutable('node')!==node)
|
|
78
|
+
fail('npm shebang would use a different Node executable');
|
|
79
|
+
if(process.env.NODE_OPTIONS||process.env.NODE_PATH||process.env.BASH_ENV||process.env.ENV)
|
|
80
|
+
fail('unreviewed Node or shell preload environment');
|
|
81
|
+
if(Object.keys(process.env).some(key=>key.toLowerCase().startsWith('npm_config_')))
|
|
82
|
+
fail('npm configuration environment override');
|
|
83
|
+
if(Object.keys(process.env).some(key=>key.startsWith('DYLD_')||key.startsWith('TSGO_')))
|
|
84
|
+
fail('unreviewed native runtime environment');
|
|
85
|
+
const npmRoot=path.dirname(path.dirname(npm)),prefix=path.dirname(path.dirname(path.dirname(npmRoot)));
|
|
86
|
+
if(path.basename(npmRoot)!=='npm'||path.basename(path.dirname(npmRoot))!=='node_modules')
|
|
87
|
+
fail('npm installation layout changed');
|
|
88
|
+
if(JSON.parse(fs.readFileSync(path.join(npmRoot,'package.json'),'utf8')).version!==npmVersion||
|
|
89
|
+
JSON.parse(fs.readFileSync(path.join(root,'node_modules/typescript/package.json'),'utf8'))
|
|
90
|
+
.version!==typescriptVersion)fail('reviewed npm or TypeScript version changed');
|
|
91
|
+
const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
|
92
|
+
if(pkg.scripts?.typecheck!=='tsc --noEmit'||pkg.scripts?.pretypecheck||pkg.scripts?.posttypecheck)
|
|
93
|
+
fail('typecheck script or lifecycle changed');
|
|
94
|
+
const tsconfig=path.join(root,'tsconfig.json');
|
|
95
|
+
if(fileHash(tsconfig)!==reviewedTsconfigHash)fail('TypeScript configuration needs review');
|
|
96
|
+
const tsc=real(path.join(root,'node_modules','.bin','tsc'));
|
|
97
|
+
if(tsc!==path.join(root,'node_modules','typescript','bin','tsc'))
|
|
98
|
+
fail('typecheck executable does not resolve to local TypeScript');
|
|
99
|
+
const listed=spawnSync(tsc,['--noEmit','--listFilesOnly'],{cwd:root,env:process.env,
|
|
100
|
+
encoding:'utf8',timeout:10000,maxBuffer:4*1024*1024,stdio:['ignore','pipe','pipe']});
|
|
101
|
+
if(listed.error||listed.signal||listed.status!==0)fail('TypeScript input listing unavailable');
|
|
102
|
+
const files=listed.stdout.trim().split(/\r?\n/).filter(Boolean).map(file=>path.resolve(file));
|
|
103
|
+
if(!files.length||files.some(file=>!inside(file,root)||
|
|
104
|
+
!(inside(file,path.join(root,'src'))||file===path.join(root,'vite.config.ts')||
|
|
105
|
+
inside(file,path.join(root,'node_modules')))))
|
|
106
|
+
fail('TypeScript consumes an input outside the observed file sets');
|
|
107
|
+
const ancestors=[];
|
|
108
|
+
for(let dir=path.dirname(root);;dir=path.dirname(dir)){
|
|
109
|
+
ancestors.push([dir,namesAt(path.join(dir,'node_modules'))]);
|
|
110
|
+
if(dir===path.dirname(dir))break;
|
|
111
|
+
}
|
|
112
|
+
const configFiles=[path.join(root,'.npmrc'),path.join(home,'.npmrc'),
|
|
113
|
+
path.join(prefix,'etc','npmrc')];
|
|
114
|
+
const npmConfig=configFiles.map(file=>[file,rcKeys(file)]);
|
|
115
|
+
// Metadata alone is insufficient for the installed npm CLI and native tools.
|
|
116
|
+
const fingerprint=sha(JSON.stringify({schema:1,probe:fileHash(fileURLToPath(import.meta.url)),
|
|
117
|
+
homeTarget,files:files.sort(),ancestors,
|
|
118
|
+
npmConfig,npmTree:treeHash(npmRoot),node:fileHash(node),shell:fileHash('/bin/sh')}));
|
|
119
|
+
process.stdout.write(fingerprint);
|
|
120
|
+
}catch(e){console.error(e.message);process.exitCode=2;}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import {mark,traceEnvironment} from './decision-profile.mjs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import {fork} from 'node:child_process';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
|
|
8
|
+
// Observation contract: live notifications establish continuity only while the
|
|
9
|
+
// subscription is healthy; history may accelerate catch-up; a fresh scan is
|
|
10
|
+
// the deterministic fallback. A failed history operation never updates cursor.
|
|
11
|
+
const workerFile=path.join(path.dirname(fileURLToPath(import.meta.url)),'history-worker.mjs');
|
|
12
|
+
// Each query runs in a fresh helper: a reused native history session can take
|
|
13
|
+
// ten times longer. The next helper is forked once a query settles, so its
|
|
14
|
+
// startup is off the decision path. A failed or late helper is killed.
|
|
15
|
+
let spare=null,queue=Promise.resolve(),closed=false;
|
|
16
|
+
// After the observer's last query no helper is kept or started.
|
|
17
|
+
export function closeHistory() {closed=true;spare?.kill('SIGKILL');spare=null;}
|
|
18
|
+
function startHelper() {
|
|
19
|
+
const child=fork(workerFile,[],{stdio:['ignore','ignore','ignore','ipc'],env:traceEnvironment()});
|
|
20
|
+
child.abandoned=new Set();child.on('error',()=>{});
|
|
21
|
+
child.once('exit',()=>{if(spare===child)spare=null;
|
|
22
|
+
child.reply?.({ok:false,lost:Error(`history helper exited ${child.exitCode??child.signalCode}`)});
|
|
23
|
+
for(const next of child.abandoned)try{fs.unlinkSync(next);}catch{}});
|
|
24
|
+
return child;
|
|
25
|
+
}
|
|
26
|
+
export function history(root,cursor,backend,deadlineMs,faultFile) {
|
|
27
|
+
const run=()=>query(root,cursor,backend,deadlineMs,faultFile);
|
|
28
|
+
const result=queue.then(run,run);queue=result.catch(()=>{});return result;
|
|
29
|
+
}
|
|
30
|
+
function query(root,cursor,backend,deadlineMs,faultFile) {
|
|
31
|
+
const next=cursor+'.'+randomUUID()+'.next';
|
|
32
|
+
return new Promise((resolve,reject)=>{
|
|
33
|
+
mark('history.submit',{deadlineMs});
|
|
34
|
+
const worker=spare??startHelper();spare=null;
|
|
35
|
+
worker.ref();worker.channel?.ref();
|
|
36
|
+
let settled=false;
|
|
37
|
+
const finish=(error,value)=>{
|
|
38
|
+
if(settled)return;settled=true;worker.reply=null;clearTimeout(timer);
|
|
39
|
+
mark('history.complete',{childPid:worker.pid,outcome:error?.code||error?.name||'ok',events:value?.length});
|
|
40
|
+
if(error)worker.abandoned.add(next);
|
|
41
|
+
worker.kill('SIGKILL');
|
|
42
|
+
// A failed query disables history for this observer; keep no helper.
|
|
43
|
+
if(!closed&&!error){spare=startHelper();spare.unref();spare.channel?.unref();}
|
|
44
|
+
if(error){try{fs.unlinkSync(next);}catch{}
|
|
45
|
+
reject(error);return;}
|
|
46
|
+
try{fs.renameSync(next,cursor);resolve(value);}
|
|
47
|
+
catch(e){try{fs.unlinkSync(next);}catch{}reject(e);}
|
|
48
|
+
};
|
|
49
|
+
const timer=setTimeout(()=>{
|
|
50
|
+
const e=Error(`history query did not settle within ${deadlineMs} ms`);
|
|
51
|
+
e.code='ETIMEDOUT';finish(e);
|
|
52
|
+
},deadlineMs);
|
|
53
|
+
worker.reply=message=>message.lost?finish(message.lost):message.ok?finish(null,message.changes):
|
|
54
|
+
finish(Object.assign(Error(message.error),{code:message.code}));
|
|
55
|
+
worker.on('message',message=>worker.reply?.(message));
|
|
56
|
+
try{worker.send({root,previous:fs.existsSync(cursor)?cursor:'',next,backend,faultFile:faultFile||''});}
|
|
57
|
+
catch(e){finish(e);}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Resolve the destination, including linked parent directories, before any
|
|
2
|
+
// state creation or removal. A watched root is readable, never state-owned.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
6
|
+
|
|
7
|
+
export function resolvedDestination(file) {
|
|
8
|
+
let current=path.resolve(file),rest=[];
|
|
9
|
+
for(;;) {
|
|
10
|
+
try {
|
|
11
|
+
const canonical=realObservedPath(current);
|
|
12
|
+
return path.join(canonical,...rest);
|
|
13
|
+
} catch(e) {
|
|
14
|
+
if(e.code!=='ENOENT')throw e;
|
|
15
|
+
const parent=path.dirname(current);
|
|
16
|
+
if(parent===current)throw Error('state destination cannot be resolved');
|
|
17
|
+
rest.unshift(path.basename(current));current=parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assertOwnedStatePlacement(config) {
|
|
23
|
+
const state=path.resolve(config.state),resolved=resolvedDestination(state);
|
|
24
|
+
const observed=[config.root,...(config.checks||[]).flatMap(check=>
|
|
25
|
+
check.allowedExternalRoots||[])].map(resolvedDestination);
|
|
26
|
+
for(const boundary of observed)if(within(resolved,boundary)||within(boundary,resolved))
|
|
27
|
+
throw Error('REDUE state overlaps an observed checkout or external root');
|
|
28
|
+
try{if(fs.lstatSync(state).isSymbolicLink())
|
|
29
|
+
throw Error('REDUE state directory must not be a symlink');}
|
|
30
|
+
catch(e){if(e.code!=='ENOENT')throw e;}
|
|
31
|
+
return {state,resolved};
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// Windows callers may supply an 8.3 spelling while filesystem APIs and
|
|
5
|
+
// TypeScript report the long name. Canonicalize existing paths at observation
|
|
6
|
+
// boundaries; keep logical link paths separately for invalidation.
|
|
7
|
+
export function realObservedPath(value) {
|
|
8
|
+
return process.platform==='win32'?fs.realpathSync.native(value):fs.realpathSync(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Use the host path implementation in production. Passing path.win32 makes
|
|
12
|
+
// the identity rules testable elsewhere; those tests are not NTFS acceptance.
|
|
13
|
+
export function normalizedPath(value,paths=path) {
|
|
14
|
+
const resolved=paths.resolve(value);
|
|
15
|
+
if(paths!==path.win32)return resolved;
|
|
16
|
+
if(/^\\\\\?\\UNC\\/i.test(resolved))return '\\\\'+resolved.slice(8);
|
|
17
|
+
if(/^\\\\\?\\[A-Za-z]:\\/.test(resolved))return resolved.slice(4);
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function samePath(left,right,paths=path) {
|
|
22
|
+
return paths.relative(normalizedPath(left,paths),normalizedPath(right,paths))==='';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function withinPath(file,root,paths=path) {
|
|
26
|
+
const relative=paths.relative(normalizedPath(root,paths),normalizedPath(file,paths));
|
|
27
|
+
return relative===''||(!paths.isAbsolute(relative)&&relative!=='..'&&
|
|
28
|
+
!relative.startsWith('..'+paths.sep));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function observedRelative(root,file,paths=path) {
|
|
32
|
+
if(!withinPath(file,root,paths))return null;
|
|
33
|
+
return paths.relative(normalizedPath(root,paths),normalizedPath(file,paths))
|
|
34
|
+
.split(paths.sep).join('/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validInputRelative(rel,platform=process.platform) {
|
|
38
|
+
if(typeof rel!=='string'||!rel||path.posix.isAbsolute(rel)||
|
|
39
|
+
rel.split('/').includes('..'))return false;
|
|
40
|
+
if(platform==='win32'&&(path.win32.isAbsolute(rel)||/^[A-Za-z]:/.test(rel)||
|
|
41
|
+
rel.includes('\\')))return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|