@redue/cli 0.0.0-stage → 0.1.0-alpha.2
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 +22 -0
- package/LICENSE +180 -0
- package/README.md +123 -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/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 +27 -0
- package/src/cli.mjs +390 -0
- package/src/control-endpoint.mjs +11 -0
- package/src/daemon.mjs +989 -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 +37 -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,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Linux-only inotify transport. Emits paths and continuity faults, never file data.
|
|
3
|
+
|
|
4
|
+
Unlike the pinned Parcel inotify backend, IN_Q_OVERFLOW is reported. The Node
|
|
5
|
+
engine owns all content inspection and deterministic reconciliation.
|
|
6
|
+
"""
|
|
7
|
+
import ctypes
|
|
8
|
+
import errno
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import select
|
|
13
|
+
import struct
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
IN_MODIFY = 0x00000002
|
|
18
|
+
IN_ATTRIB = 0x00000004
|
|
19
|
+
IN_CLOSE_WRITE = 0x00000008
|
|
20
|
+
IN_MOVED_FROM = 0x00000040
|
|
21
|
+
IN_MOVED_TO = 0x00000080
|
|
22
|
+
IN_CREATE = 0x00000100
|
|
23
|
+
IN_DELETE = 0x00000200
|
|
24
|
+
IN_DELETE_SELF = 0x00000400
|
|
25
|
+
IN_MOVE_SELF = 0x00000800
|
|
26
|
+
IN_UNMOUNT = 0x00002000
|
|
27
|
+
IN_Q_OVERFLOW = 0x00004000
|
|
28
|
+
IN_IGNORED = 0x00008000
|
|
29
|
+
IN_ISDIR = 0x40000000
|
|
30
|
+
FILE_MASK = IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | IN_DELETE_SELF | IN_MOVE_SELF
|
|
31
|
+
MASK = (IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | IN_MOVED_FROM |
|
|
32
|
+
IN_MOVED_TO | IN_CREATE | IN_DELETE | IN_DELETE_SELF |
|
|
33
|
+
IN_MOVE_SELF | IN_UNMOUNT)
|
|
34
|
+
HEADER = struct.Struct('iIII')
|
|
35
|
+
LOCAL_FILESYSTEMS = {'ext2', 'ext3', 'ext4', 'xfs', 'btrfs', 'f2fs',
|
|
36
|
+
'tmpfs', 'zfs'}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit(kind, **fields):
|
|
40
|
+
print(json.dumps({'kind': kind, **fields}, ensure_ascii=True), flush=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def filesystem_type(folder):
|
|
44
|
+
best = ('', None)
|
|
45
|
+
with open('/proc/self/mountinfo', encoding='utf-8') as mounts:
|
|
46
|
+
for line in mounts:
|
|
47
|
+
before, after = line.rstrip('\n').split(' - ', 1)
|
|
48
|
+
point = re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)),
|
|
49
|
+
before.split()[4])
|
|
50
|
+
if (folder == point or folder.startswith(point.rstrip('/') + '/')) \
|
|
51
|
+
and len(point) > len(best[0]):
|
|
52
|
+
best = (point, after.split()[0])
|
|
53
|
+
return best[1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(root):
|
|
57
|
+
root = os.path.realpath(root)
|
|
58
|
+
if filesystem_type(root) not in LOCAL_FILESYSTEMS:
|
|
59
|
+
emit('gap', reason='inotify_filesystem_untrusted')
|
|
60
|
+
return 2
|
|
61
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
62
|
+
libc.inotify_init1.argtypes = [ctypes.c_int]
|
|
63
|
+
libc.inotify_init1.restype = ctypes.c_int
|
|
64
|
+
libc.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
|
|
65
|
+
libc.inotify_add_watch.restype = ctypes.c_int
|
|
66
|
+
libc.inotify_rm_watch.argtypes = [ctypes.c_int, ctypes.c_int]
|
|
67
|
+
libc.inotify_rm_watch.restype = ctypes.c_int
|
|
68
|
+
fd = libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC)
|
|
69
|
+
if fd < 0:
|
|
70
|
+
raise OSError(ctypes.get_errno(), 'inotify_init1 failed')
|
|
71
|
+
watches = {}
|
|
72
|
+
file_watches = {}
|
|
73
|
+
file_paths = {}
|
|
74
|
+
intentional_removals = set()
|
|
75
|
+
|
|
76
|
+
def add_tree(folder):
|
|
77
|
+
stack = [folder]
|
|
78
|
+
while stack:
|
|
79
|
+
current = stack.pop()
|
|
80
|
+
wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
|
|
81
|
+
if wd < 0:
|
|
82
|
+
raise OSError(ctypes.get_errno(), 'inotify_add_watch failed')
|
|
83
|
+
watches[wd] = current
|
|
84
|
+
with os.scandir(current) as entries:
|
|
85
|
+
for entry in entries:
|
|
86
|
+
if entry.is_dir(follow_symlinks=False):
|
|
87
|
+
stack.append(entry.path)
|
|
88
|
+
|
|
89
|
+
def drain():
|
|
90
|
+
while True:
|
|
91
|
+
try:
|
|
92
|
+
block = os.read(fd, 65536)
|
|
93
|
+
except BlockingIOError:
|
|
94
|
+
return True
|
|
95
|
+
if not block:
|
|
96
|
+
emit('gap', reason='inotify_closed')
|
|
97
|
+
return False
|
|
98
|
+
offset = 0
|
|
99
|
+
while offset < len(block):
|
|
100
|
+
wd, mask, _, length = HEADER.unpack_from(block, offset)
|
|
101
|
+
offset += HEADER.size
|
|
102
|
+
raw = block[offset:offset + length].split(b'\0', 1)[0]
|
|
103
|
+
offset += length
|
|
104
|
+
if mask & IN_Q_OVERFLOW:
|
|
105
|
+
emit('gap', reason='inotify_overflow')
|
|
106
|
+
return False
|
|
107
|
+
if wd in intentional_removals and mask & IN_IGNORED:
|
|
108
|
+
intentional_removals.remove(wd)
|
|
109
|
+
continue
|
|
110
|
+
if wd in file_watches:
|
|
111
|
+
if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
|
|
112
|
+
emit('gap', reason='inotify_file_watch_lost')
|
|
113
|
+
return False
|
|
114
|
+
for watched in file_watches[wd]:
|
|
115
|
+
emit('event', type='update', path=watched)
|
|
116
|
+
continue
|
|
117
|
+
parent = watches.get(wd)
|
|
118
|
+
if parent is None:
|
|
119
|
+
emit('gap', reason='inotify_watch_unknown')
|
|
120
|
+
return False
|
|
121
|
+
target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
|
|
122
|
+
if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
|
|
123
|
+
emit('gap', reason='inotify_watch_lost')
|
|
124
|
+
return False
|
|
125
|
+
if (mask & IN_ISDIR) and (mask & (IN_CREATE | IN_MOVED_TO)):
|
|
126
|
+
try:
|
|
127
|
+
add_tree(target)
|
|
128
|
+
except OSError as error:
|
|
129
|
+
emit('gap', reason='inotify_watch_add_failed', errno=error.errno)
|
|
130
|
+
return False
|
|
131
|
+
# Contents can appear before their new directory receives
|
|
132
|
+
# a watch. The engine reconciles instead of guessing.
|
|
133
|
+
emit('gap', reason='inotify_new_directory')
|
|
134
|
+
return False
|
|
135
|
+
if (mask & IN_ISDIR) and (mask & (IN_DELETE | IN_MOVED_FROM)):
|
|
136
|
+
emit('gap', reason='inotify_directory_removed')
|
|
137
|
+
return False
|
|
138
|
+
event = ('delete' if mask & (IN_DELETE | IN_MOVED_FROM) else
|
|
139
|
+
'create' if mask & (IN_CREATE | IN_MOVED_TO) else 'update')
|
|
140
|
+
emit('event', type=event, path=target)
|
|
141
|
+
|
|
142
|
+
def replace_file_watches(paths):
|
|
143
|
+
requested = set(paths)
|
|
144
|
+
removed = False
|
|
145
|
+
for old in set(file_paths) - requested:
|
|
146
|
+
wd = file_paths.pop(old)
|
|
147
|
+
aliases = file_watches[wd]
|
|
148
|
+
aliases.remove(old)
|
|
149
|
+
if not aliases:
|
|
150
|
+
del file_watches[wd]
|
|
151
|
+
intentional_removals.add(wd)
|
|
152
|
+
libc.inotify_rm_watch(fd, wd)
|
|
153
|
+
removed = True
|
|
154
|
+
# Consume IN_IGNORED before adding new watches so a recycled watch
|
|
155
|
+
# descriptor cannot be mistaken for an intentionally removed one.
|
|
156
|
+
if removed and not drain():
|
|
157
|
+
return False
|
|
158
|
+
for added in requested - set(file_paths):
|
|
159
|
+
absolute = os.path.abspath(added)
|
|
160
|
+
if os.path.commonpath((root, absolute)) != root or \
|
|
161
|
+
os.path.commonpath((root, os.path.realpath(absolute))) != root:
|
|
162
|
+
raise ValueError('installed watch escaped checkout')
|
|
163
|
+
wd = libc.inotify_add_watch(fd, os.fsencode(absolute), FILE_MASK)
|
|
164
|
+
if wd < 0:
|
|
165
|
+
raise OSError(ctypes.get_errno(), 'installed inotify_add_watch failed')
|
|
166
|
+
file_paths[added] = wd
|
|
167
|
+
file_watches.setdefault(wd, set()).add(added)
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
add_tree(root)
|
|
172
|
+
emit('ready', watches=len(watches))
|
|
173
|
+
stdin = b''
|
|
174
|
+
heartbeat = time.monotonic()
|
|
175
|
+
while True:
|
|
176
|
+
injected = os.environ.get('VSTATE_TEST_LINUX_GAP_FILE') if \
|
|
177
|
+
os.environ.get('VSTATE_TEST_FAULTS') == '1' else None
|
|
178
|
+
if injected and os.path.exists(injected):
|
|
179
|
+
with open(injected, encoding='utf-8') as source:
|
|
180
|
+
reason = source.read(64).strip()
|
|
181
|
+
os.unlink(injected)
|
|
182
|
+
emit('gap', reason=reason if reason in
|
|
183
|
+
('inotify_overflow', 'inotify_watch_lost',
|
|
184
|
+
'inotify_watch_add_failed') else 'inotify_test_gap')
|
|
185
|
+
return 2
|
|
186
|
+
readable, _, _ = select.select([fd, 0], [], [], 0.25)
|
|
187
|
+
if fd in readable and not drain():
|
|
188
|
+
return 2
|
|
189
|
+
if 0 in readable:
|
|
190
|
+
chunk = os.read(0, 4096)
|
|
191
|
+
if not chunk:
|
|
192
|
+
return 0
|
|
193
|
+
stdin += chunk
|
|
194
|
+
while b'\n' in stdin:
|
|
195
|
+
line, stdin = stdin.split(b'\n', 1)
|
|
196
|
+
message = json.loads(line)
|
|
197
|
+
if message.get('kind') == 'barrier':
|
|
198
|
+
hang = os.environ.get('VSTATE_TEST_LINUX_HANG_FILE') if \
|
|
199
|
+
os.environ.get('VSTATE_TEST_FAULTS') == '1' else None
|
|
200
|
+
if hang and os.path.exists(hang):
|
|
201
|
+
continue
|
|
202
|
+
if not drain():
|
|
203
|
+
return 2
|
|
204
|
+
emit('barrier', id=message['id'])
|
|
205
|
+
elif message.get('kind') == 'watch-files':
|
|
206
|
+
try:
|
|
207
|
+
if not replace_file_watches(message['paths']):
|
|
208
|
+
return 2
|
|
209
|
+
except (OSError, ValueError) as error:
|
|
210
|
+
emit('gap', reason='inotify_file_watch_add_failed',
|
|
211
|
+
errno=getattr(error, 'errno', None))
|
|
212
|
+
return 2
|
|
213
|
+
if not drain():
|
|
214
|
+
return 2
|
|
215
|
+
emit('watched', id=message['id'], files=len(file_paths))
|
|
216
|
+
if time.monotonic() - heartbeat >= 1:
|
|
217
|
+
emit('heartbeat', watches=len(watches))
|
|
218
|
+
heartbeat = time.monotonic()
|
|
219
|
+
finally:
|
|
220
|
+
os.close(fd)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == '__main__':
|
|
224
|
+
try:
|
|
225
|
+
sys.exit(main(sys.argv[1]))
|
|
226
|
+
except (OSError, ValueError, IndexError) as error:
|
|
227
|
+
emit('gap', reason='inotify_backend_error', errno=getattr(error, 'errno', None))
|
|
228
|
+
sys.exit(2)
|
|
@@ -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,263 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {spawnSync} from 'node:child_process';
|
|
4
|
+
import micromatch from './glob.mjs';
|
|
5
|
+
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
6
|
+
import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
7
|
+
import {realObservedPath} from './path-identity.mjs';
|
|
8
|
+
import {findExecutable} from './executable-lookup.mjs';
|
|
9
|
+
|
|
10
|
+
const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
|
|
11
|
+
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
12
|
+
const scriptNames={
|
|
13
|
+
typecheck:['typecheck','type-check','check:types','types:check'],
|
|
14
|
+
test:['test','test:unit','test:check'],
|
|
15
|
+
lint:['lint','lint:check'],
|
|
16
|
+
build:['build'],
|
|
17
|
+
};
|
|
18
|
+
const sourceExtensions='**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json,jsonc}';
|
|
19
|
+
const likelyDirs=['src','app','apps','pages','lib','packages','test','tests','__tests__','scripts'];
|
|
20
|
+
const toolConfigs=['tsconfig.json','tsconfig.base.json','vite.config.ts','vite.config.js',
|
|
21
|
+
'vitest.config.ts','vitest.config.js','jest.config.js','jest.config.ts',
|
|
22
|
+
'eslint.config.js','eslint.config.mjs','.eslintrc','.eslintrc.json',
|
|
23
|
+
'.eslintrc.js','.npmrc','.yarnrc.yml'];
|
|
24
|
+
function installedNpmVersion() {
|
|
25
|
+
try{
|
|
26
|
+
const executable=findExecutable('npm');
|
|
27
|
+
if(!executable)return null;
|
|
28
|
+
const manifest=readJson(process.platform==='win32'?
|
|
29
|
+
path.join(path.dirname(executable),'node_modules','npm','package.json'):
|
|
30
|
+
path.join(path.dirname(path.dirname(executable)),'package.json'));
|
|
31
|
+
if(manifest.name==='npm')return manifest.version;
|
|
32
|
+
}catch{}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function manager(root,pkg) {
|
|
37
|
+
const declared=pkg.packageManager?.match(/^(npm|yarn|pnpm|bun)@([^+\s]+)/);
|
|
38
|
+
const locks=['package-lock.json','npm-shrinkwrap.json','yarn.lock','pnpm-lock.yaml',
|
|
39
|
+
'bun.lock','bun.lockb'].filter(name=>exists(path.join(root,name)));
|
|
40
|
+
const kinds=new Set(locks.map(name=>name.startsWith('package-lock')||name==='npm-shrinkwrap.json'?
|
|
41
|
+
'npm':name==='yarn.lock'?'yarn':name.startsWith('pnpm')?'pnpm':'bun'));
|
|
42
|
+
if(declared)kinds.add(declared[1]);
|
|
43
|
+
if(kinds.size!==1)return {name:null,version:declared?.[2]||null,layout:'unknown',
|
|
44
|
+
issue:kinds.size?'package-manager declaration conflicts with lockfiles':
|
|
45
|
+
'no supported lockfile or packageManager declaration'};
|
|
46
|
+
const name=[...kinds][0],version=declared?.[1]===name?declared[2]:null;
|
|
47
|
+
if(name==='bun')return {name,version,layout:'unsupported',
|
|
48
|
+
issue:'bun installation layout is not supported in this alpha'};
|
|
49
|
+
if(name==='pnpm'){
|
|
50
|
+
try{const installed=pnpmInstallInfo(root);
|
|
51
|
+
return {name,version:installed.version,
|
|
52
|
+
layout:`node-modules/${installed.linker}`,issue:null,lockfiles:locks,
|
|
53
|
+
workspacePatterns:installed.patterns};}
|
|
54
|
+
catch(e){let project;try{project=pnpmProjectInfo(root);}catch{}
|
|
55
|
+
return {name,version:project?.version||version,
|
|
56
|
+
layout:project?.linker||'recording-only',issue:e.message,
|
|
57
|
+
lockfiles:locks,workspacePatterns:project?.patterns||[]};}
|
|
58
|
+
}
|
|
59
|
+
if(name==='npm')return {name,version:version||installedNpmVersion(),
|
|
60
|
+
layout:'node-modules',issue:null,
|
|
61
|
+
lockfiles:locks};
|
|
62
|
+
const yarnrc=path.join(root,'.yarnrc.yml');
|
|
63
|
+
if(!exists(yarnrc)){
|
|
64
|
+
const classicLock=exists(path.join(root,'yarn.lock'))&&
|
|
65
|
+
/^# yarn lockfile v1\s*$/m.test(fs.readFileSync(path.join(root,'yarn.lock'),'utf8').slice(0,256));
|
|
66
|
+
const classic=version?.startsWith('1.')||(!version&&classicLock);
|
|
67
|
+
return {name,version:version||null,layout:classic?'node-modules':'unknown',
|
|
68
|
+
issue:!version?'Yarn version is not declared; Corepack may select a different version or edit package.json':
|
|
69
|
+
classic?null:'Yarn linker is not declared; node-modules layout cannot be assumed',
|
|
70
|
+
lockfiles:locks};
|
|
71
|
+
}
|
|
72
|
+
const text=fs.readFileSync(yarnrc,'utf8');
|
|
73
|
+
const linker=text.match(/^\s*nodeLinker\s*:\s*['"]?([\w-]+)/m)?.[1];
|
|
74
|
+
const mode=text.match(/^\s*nmMode\s*:\s*['"]?([\w-]+)/m)?.[1]||'classic';
|
|
75
|
+
if(linker!=='node-modules')return {name,version,layout:linker||'unknown',
|
|
76
|
+
issue:`Yarn ${linker||'unspecified'} linker is outside the node-modules alpha path`,
|
|
77
|
+
lockfiles:locks};
|
|
78
|
+
if(mode!=='classic')return {name,version,layout:`node-modules/${mode}`,
|
|
79
|
+
issue:`Yarn nmMode ${mode} is not yet observed safely`,lockfiles:locks};
|
|
80
|
+
return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
|
|
81
|
+
}
|
|
82
|
+
const exactTypeScript=script=>script.trim()==='tsc --noEmit';
|
|
83
|
+
function sourceLinkIssue(root) {
|
|
84
|
+
const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
|
|
85
|
+
let inspected=0;
|
|
86
|
+
while(stack.length){
|
|
87
|
+
const rel=stack.pop(),full=path.join(root,rel);
|
|
88
|
+
if(fs.lstatSync(full).isSymbolicLink())
|
|
89
|
+
return `${rel} is a source link; declare its permitted observation boundary explicitly`;
|
|
90
|
+
let entries;try{entries=fs.readdirSync(full,{withFileTypes:true});}
|
|
91
|
+
catch{return `${rel} cannot be inspected; source input coverage needs review`;}
|
|
92
|
+
for(const entry of entries){
|
|
93
|
+
if(++inspected>100000)return 'source tree is too large for init link inspection';
|
|
94
|
+
const child=`${rel}/${entry.name}`;
|
|
95
|
+
if(entry.isSymbolicLink())return `${child} is a source link; declare its permitted observation boundary explicitly`;
|
|
96
|
+
if(entry.isDirectory()&&!['.git','node_modules'].includes(entry.name))stack.push(child);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function sourceInputs(root,kind,lockfiles) {
|
|
102
|
+
const files=['package.json',...lockfiles,...toolConfigs.filter(name=>exists(path.join(root,name)))];
|
|
103
|
+
if(kind==='typecheck')return [...new Set(['package.json',...lockfiles,
|
|
104
|
+
'tsconfig.json',sourceExtensions,
|
|
105
|
+
'!node_modules/**','!.git/**'])];
|
|
106
|
+
const dirs=likelyDirs.filter(name=>exists(path.join(root,name)))
|
|
107
|
+
.map(name=>`${name}/**`);
|
|
108
|
+
return [...new Set([...files,...(dirs.length?dirs:['package.json'])])];
|
|
109
|
+
}
|
|
110
|
+
function classify(kind,scriptName,script,pkg,managerInfo,root) {
|
|
111
|
+
const value=script.trim();
|
|
112
|
+
if(managerInfo.issue)return {level:managerInfo.name==='pnpm'?'recording':'unsupported',
|
|
113
|
+
reason:managerInfo.issue};
|
|
114
|
+
if(kind==='typecheck'&&exactTypeScript(value)&&
|
|
115
|
+
!pkg.scripts?.['pre'+scriptName]&&!pkg.scripts?.['post'+scriptName]&&
|
|
116
|
+
managerInfo.name==='npm'&&
|
|
117
|
+
exists(path.join(root,'tsconfig.json'))&&
|
|
118
|
+
exists(path.join(root,'node_modules/typescript/package.json')))
|
|
119
|
+
return {level:'ready',reason:'canonical local tsc --noEmit; checked again before reuse',
|
|
120
|
+
qualification:'typescript-noemit-v1'};
|
|
121
|
+
if(kind==='typecheck')return {level:'recording',reason:
|
|
122
|
+
'TypeScript invocation, installation, or configuration is outside the automatic noEmit contract'};
|
|
123
|
+
if(kind==='test')return {level:'recording',reason:/\b(vitest|jest)\b/.test(value)?
|
|
124
|
+
'test framework detected; caches, transforms and runtime inputs need review':
|
|
125
|
+
'test script detected; runtime and generated inputs need review'};
|
|
126
|
+
if(kind==='lint')return {level:'recording',reason:/\beslint\b/.test(value)?
|
|
127
|
+
'ESLint detected; plugin/config and resolver inputs need review':
|
|
128
|
+
'lint script detected; tool and config inputs need review'};
|
|
129
|
+
return {level:'recording',reason:/\bvite\b/.test(value)?
|
|
130
|
+
'Vite detected; build environment and generated inputs need review':
|
|
131
|
+
'build script detected; generated and runtime inputs need review'};
|
|
132
|
+
}
|
|
133
|
+
function selectScript(kind,names,scripts) {
|
|
134
|
+
const preferred=names.find(name=>typeof scripts[name]==='string'&&
|
|
135
|
+
scripts[name].trim()&&!/^echo\s+["']?Error: no test specified/.test(scripts[name]));
|
|
136
|
+
if(preferred)return {script:preferred};
|
|
137
|
+
const candidates=Object.keys(scripts).filter(name=>{
|
|
138
|
+
const value=scripts[name]?.trim();
|
|
139
|
+
if(typeof value!=='string')return false;
|
|
140
|
+
if(kind==='typecheck')return exactTypeScript(value);
|
|
141
|
+
if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
|
|
142
|
+
if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
|
|
143
|
+
return /^vite\s+build(?:\s|$)/.test(value);
|
|
144
|
+
});
|
|
145
|
+
return candidates.length===1?{script:candidates[0]}:
|
|
146
|
+
candidates.length>1?{ambiguous:candidates}:{};
|
|
147
|
+
}
|
|
148
|
+
function workspaceManifests(root,patterns) {
|
|
149
|
+
const listing=spawnSync('git',['ls-files','--cached','--others','--exclude-standard',
|
|
150
|
+
'-z','--',':(glob)**/package.json'],{cwd:root,encoding:'utf8',timeout:5000,
|
|
151
|
+
maxBuffer:32*1024*1024,stdio:['ignore','pipe','ignore'],
|
|
152
|
+
env:{...process.env,GIT_OPTIONAL_LOCKS:'0'}});
|
|
153
|
+
if(listing.status!==0||listing.error)throw Error('workspace package manifests cannot be inspected');
|
|
154
|
+
const matched=listing.stdout.split('\0').filter(Boolean).filter(file=>{
|
|
155
|
+
const dir=path.posix.dirname(file);
|
|
156
|
+
return dir!=='.'&&!dir.split('/').includes('node_modules')&&
|
|
157
|
+
micromatch.isMatch(dir,patterns,{dot:true});
|
|
158
|
+
}).sort();
|
|
159
|
+
if(matched.length>50)throw Error('more than 50 workspace packages found; select checks explicitly');
|
|
160
|
+
return matched.map(file=>({file,pkg:readJson(path.join(root,file))}));
|
|
161
|
+
}
|
|
162
|
+
export function discoverNodeProject(root) {
|
|
163
|
+
root=realObservedPath(root);
|
|
164
|
+
const packageFile=path.join(root,'package.json');
|
|
165
|
+
if(!exists(packageFile))throw Error('run init from a repository containing package.json');
|
|
166
|
+
const pkg=readJson(packageFile),pm=manager(root,pkg),scripts=pkg.scripts||{};
|
|
167
|
+
const git=spawnSync('git',['rev-parse','--show-toplevel'],{cwd:root,
|
|
168
|
+
encoding:'utf8',timeout:3000,stdio:['ignore','pipe','ignore']});
|
|
169
|
+
const repositoryIssue=git.status===0?null:
|
|
170
|
+
'Git checkout is unavailable; filesystem observation needs a checkout';
|
|
171
|
+
const observationIssue=repositoryIssue||sourceLinkIssue(root);
|
|
172
|
+
const workspacePatterns=pm.name==='pnpm'?pm.workspacePatterns:
|
|
173
|
+
Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
|
|
174
|
+
const workspaces=Boolean(pkg.workspaces)||Boolean(workspacePatterns?.length);
|
|
175
|
+
if(workspaces&&(!Array.isArray(workspacePatterns)||
|
|
176
|
+
workspacePatterns.some(value=>typeof value!=='string'||path.isAbsolute(value)||
|
|
177
|
+
value.split('/').includes('..'))))
|
|
178
|
+
throw Error('workspace patterns need a supported project-relative package list');
|
|
179
|
+
const checks=[],ambiguous=[];
|
|
180
|
+
for(const [kind,names] of Object.entries(scriptNames)) {
|
|
181
|
+
const selected=selectScript(kind,names,scripts),script=selected.script;
|
|
182
|
+
if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
|
|
183
|
+
if(!script)continue;
|
|
184
|
+
let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
|
|
185
|
+
classify(kind,script,scripts[script],pkg,pm,root);
|
|
186
|
+
if(workspaces&&classification.level==='ready')classification={level:'recording',
|
|
187
|
+
reason:'workspace task and installed-input closure need explicit qualification'};
|
|
188
|
+
const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
|
|
189
|
+
if(pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&
|
|
190
|
+
/^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(scripts[script].trim())){
|
|
191
|
+
try{const contract=pnpmTypecheckInputs(root,'.',script);
|
|
192
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
193
|
+
check.command=['@node','@typescript-bin:.',
|
|
194
|
+
...scripts[script].trim().split(/\s+/).slice(1)];
|
|
195
|
+
check.cwd='.';
|
|
196
|
+
check.inputs=['package.json','pnpm-lock.yaml',
|
|
197
|
+
...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
|
|
198
|
+
'tsconfig.json','src/**'];
|
|
199
|
+
classification={level:'ready',reason:'pinned pnpm TypeScript inputs and installation; checked again before reuse',
|
|
200
|
+
qualification:'pnpm-tsc-v1'};}
|
|
201
|
+
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
202
|
+
}
|
|
203
|
+
if(classification.qualification)check.qualification=classification.qualification;
|
|
204
|
+
checks.push({config:check,...classification});
|
|
205
|
+
}
|
|
206
|
+
if(workspaces&&!observationIssue){
|
|
207
|
+
const seen=new Set();
|
|
208
|
+
for(const {file,pkg:member} of workspaceManifests(root,workspacePatterns)){
|
|
209
|
+
const dir=path.posix.dirname(file),name=member.name;
|
|
210
|
+
if(typeof name!=='string'||!name||seen.has(name)){
|
|
211
|
+
ambiguous.push({kind:'workspace',scripts:[dir],reason:'missing or duplicate package name'});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
seen.add(name);
|
|
215
|
+
if(!['npm','yarn','pnpm'].includes(pm.name))continue;
|
|
216
|
+
for(const [kind,names] of Object.entries(scriptNames)){
|
|
217
|
+
const selected=selectScript(kind,names,member.scripts||{});
|
|
218
|
+
if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
|
|
219
|
+
scripts:selected.ambiguous});continue;}
|
|
220
|
+
if(!selected.script)continue;
|
|
221
|
+
const command=pm.name==='npm'?['@which:npm','run',selected.script,
|
|
222
|
+
'--workspace',name]:pm.name==='yarn'?
|
|
223
|
+
['@which:yarn','workspace',name,'run',selected.script]:
|
|
224
|
+
['@which:pnpm','--filter',name,'run',selected.script];
|
|
225
|
+
const check={name:`${name}:${kind}`,kind,command,
|
|
226
|
+
inputs:['package.json',...(pm.lockfiles||[]),`${dir}/**`,
|
|
227
|
+
`!${dir}/node_modules/**`]};
|
|
228
|
+
let level=pm.issue&&pm.name!=='pnpm'?'unsupported':'recording';
|
|
229
|
+
let reason=pm.issue||'workspace script found; shared configuration and installed inputs need review';
|
|
230
|
+
if(!pm.issue&&pm.name==='yarn'&&pm.layout==='node-modules/classic'&&
|
|
231
|
+
kind==='typecheck'&&(member.scripts||{})[selected.script]?.trim()==='tsc -p .'&&
|
|
232
|
+
!member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
|
|
233
|
+
try{
|
|
234
|
+
yarnWorkspaceTypecheckInputs(root,name,selected.script);
|
|
235
|
+
check.workspace=name;check.qualification='yarn-workspace-tsc-v1';
|
|
236
|
+
check.inputs=['package.json','yarn.lock','.yarnrc.yml',file,`${dir}/src/**`];
|
|
237
|
+
level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
|
|
238
|
+
}catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
|
|
239
|
+
}
|
|
240
|
+
if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
|
|
241
|
+
/^(?:tsc --noEmit|tsc -p (?:\.|tsconfig\.json)(?: --noEmit)?)$/.test(
|
|
242
|
+
(member.scripts||{})[selected.script]?.trim()||'')&&
|
|
243
|
+
!member.scripts?.['pre'+selected.script]&&!member.scripts?.['post'+selected.script]){
|
|
244
|
+
try{const contract=pnpmTypecheckInputs(root,name,selected.script);
|
|
245
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
246
|
+
check.workspace=name;check.qualification='pnpm-tsc-v1';
|
|
247
|
+
check.script=selected.script;
|
|
248
|
+
check.cwd=dir;
|
|
249
|
+
check.command=['@node',`@typescript-bin:${dir}`,
|
|
250
|
+
...member.scripts[selected.script].trim().split(/\s+/).slice(1)];
|
|
251
|
+
check.inputs=['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',file,
|
|
252
|
+
`${dir}/src/**`];
|
|
253
|
+
level='ready';reason='pinned pnpm workspace TypeScript inputs; checked again before reuse';
|
|
254
|
+
}catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
|
|
255
|
+
}
|
|
256
|
+
checks.push({config:check,level,reason});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return {schema:1,root,manager:pm,observationIssue,workspaces,node:process.version,
|
|
261
|
+
checks,ambiguous,
|
|
262
|
+
config:{schema:1,packageManager:pm.name||'unknown',checks:checks.map(row=>row.config)}};
|
|
263
|
+
}
|