@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +180 -0
  3. package/README.md +123 -2
  4. package/bin/redue.mjs +329 -0
  5. package/bin/vstate.mjs +3 -0
  6. package/docs/README.md +20 -0
  7. package/docs/user/agents.md +32 -0
  8. package/docs/user/configuration.md +57 -0
  9. package/docs/user/getting-started.md +127 -0
  10. package/docs/user/json.md +59 -0
  11. package/docs/user/shell.md +25 -0
  12. package/docs/user/supported-projects.md +89 -0
  13. package/examples/redue.config.json +8 -0
  14. package/package.json +46 -4
  15. package/src/cached-state.mjs +27 -0
  16. package/src/cli.mjs +390 -0
  17. package/src/control-endpoint.mjs +11 -0
  18. package/src/daemon.mjs +989 -0
  19. package/src/decision-profile.mjs +18 -0
  20. package/src/decision-validation.mjs +41 -0
  21. package/src/declared-plan.mjs +125 -0
  22. package/src/executable-lookup.mjs +18 -0
  23. package/src/glob.mjs +9 -0
  24. package/src/history-worker.mjs +18 -0
  25. package/src/index.mjs +306 -0
  26. package/src/installed-inputs.mjs +110 -0
  27. package/src/linux-inotify.py +228 -0
  28. package/src/linux-observer.mjs +79 -0
  29. package/src/node-onboarding.mjs +263 -0
  30. package/src/npm-typecheck-probe.mjs +120 -0
  31. package/src/observation.mjs +37 -0
  32. package/src/owned-state.mjs +32 -0
  33. package/src/path-identity.mjs +43 -0
  34. package/src/plan-worker.mjs +131 -0
  35. package/src/plan.mjs +10 -0
  36. package/src/platform-observation.mjs +17 -0
  37. package/src/platform-state.mjs +14 -0
  38. package/src/pnpm-inputs.mjs +355 -0
  39. package/src/pnpm-typecheck-probe.mjs +47 -0
  40. package/src/presentation.mjs +52 -0
  41. package/src/probe-result.mjs +21 -0
  42. package/src/process-liveness.mjs +11 -0
  43. package/src/run-lock.mjs +77 -0
  44. package/src/state-store.mjs +37 -0
  45. package/src/toolchain-probe.mjs +21 -0
  46. package/src/typescript-contract-probe.mjs +188 -0
  47. package/src/typescript-list.mjs +68 -0
  48. package/src/typescript-qualification.mjs +96 -0
  49. package/src/windows-command.mjs +16 -0
  50. package/src/windows-process.mjs +15 -0
  51. package/src/yarn-workspace-inputs.mjs +148 -0
  52. 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
+ }