@redue/cli 0.1.0-alpha.24 → 0.1.0-alpha.26
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 +70 -0
- package/README.md +19 -14
- package/bin/redue.mjs +73 -13
- package/docs/README.md +1 -0
- package/docs/user/direct-typescript.md +8 -0
- package/docs/user/node-script.md +10 -6
- package/docs/user/sandbox.md +169 -0
- package/docs/user/supported-projects.md +8 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +52 -14
- package/src/cli.mjs +120 -31
- package/src/daemon.mjs +15 -4
- package/src/decision-validation.mjs +2 -1
- package/src/declared-plan.mjs +10 -1
- package/src/direct-run-certificate.mjs +3 -1
- package/src/direct-typescript-probe.mjs +4 -1
- package/src/direct-typescript.mjs +23 -3
- package/src/index.mjs +36 -3
- package/src/landlock.py +183 -0
- package/src/node-onboarding.mjs +41 -20
- package/src/node-script.mjs +16 -1
- package/src/pnpm-inputs.mjs +4 -1
- package/src/presentation.mjs +1 -1
- package/src/python-onboarding.mjs +87 -0
- package/src/sandbox.mjs +396 -0
- package/src/setup.mjs +7 -2
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {createHash} from 'node:crypto';
|
|
5
5
|
import {createRequire} from 'node:module';
|
|
6
|
-
import {racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
|
|
6
|
+
import {normalizedPath,racyUntil,realObservedPath,withinPath} from './path-identity.mjs';
|
|
7
7
|
import {cachedTarball,lockedPackage,matchTarball,npmCacheDir,registryPackage,tarballFiles} from './npm-integrity.mjs';
|
|
8
8
|
import {runConfig,tscRun} from './typescript-invocation.mjs';
|
|
9
9
|
export const directInterpretation='npm-direct-typescript@1';
|
|
@@ -79,7 +79,7 @@ export function directCompiler(root,{verify=true}={}){
|
|
|
79
79
|
for(const [name,file] of files.sort(([a],[b])=>a<b?-1:a>b?1:0))digest.update(name+'\0'+hash(fs.readFileSync(file))+'\0');
|
|
80
80
|
if(digest.digest('hex')!==spec.digest)fail('direct-typescript-implementation-unreviewed');
|
|
81
81
|
}
|
|
82
|
-
return {tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
|
|
82
|
+
return {root,tsRoot,version,entry:path.join(tsRoot,spec.entry),digest:spec.digest};
|
|
83
83
|
}
|
|
84
84
|
// A TypeScript 5.x or 6.0 the lockfile records: the installed package must be
|
|
85
85
|
// the registry's `typescript` at that version, holding exactly the files of
|
|
@@ -115,7 +115,7 @@ function lockedCompiler(root,tsRoot,version,verify){
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
const entry=fs.existsSync(path.join(tsRoot,'lib','_tsc.js'))?'lib/_tsc.js':'lib/tsc.js';
|
|
118
|
-
return {tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
|
|
118
|
+
return {root,tsRoot,version,entry:path.join(tsRoot,entry),digest:hash('npm-integrity\0'+locked.integrity)};
|
|
119
119
|
}
|
|
120
120
|
// The direct probe's output: root, script, configuration and program files with
|
|
121
121
|
// their byte digests, and the execution context. A recorded run rebuilds it from
|
|
@@ -162,6 +162,26 @@ export function directContextHash(compiler,node){
|
|
|
162
162
|
return hash(JSON.stringify({interpretation:directInterpretation,compiler:compiler.digest,
|
|
163
163
|
entry:compiler.entry,node}));
|
|
164
164
|
}
|
|
165
|
+
// Vitest writes its results under node_modules/.vite/vitest at every run. The
|
|
166
|
+
// recipe does not observe them. A compiler that asks about anything under
|
|
167
|
+
// node_modules/.vite, is told a path there (realpath), or lists node_modules,
|
|
168
|
+
// could see them, so its result is UNVERIFIED.
|
|
169
|
+
export const directInstalledInputs=Object.freeze(['node_modules/**','!node_modules/.vite/vitest/**']);
|
|
170
|
+
const listings=new Set(['readdir','listing','directories','entries','readDirectory']);
|
|
171
|
+
// The first path of [kind, file, answer] questions that does, or null. Kinds
|
|
172
|
+
// are a recorded run's (readdir) or a probe's. Compared without case: a
|
|
173
|
+
// case-insensitive mount reaches .vite under any spelling.
|
|
174
|
+
export function viteCacheQuestion(root,questions){
|
|
175
|
+
const fold=value=>normalizedPath(value).toLowerCase();
|
|
176
|
+
const modules=fold(path.join(root,'node_modules')),cache=path.join(modules,'.vite');
|
|
177
|
+
for(const [kind,file,answer] of questions){
|
|
178
|
+
if(typeof file!=='string')continue;
|
|
179
|
+
const at=fold(file);
|
|
180
|
+
if(withinPath(at,cache)||at===modules&&listings.has(kind))return file;
|
|
181
|
+
if(kind==='realpath'&&typeof answer==='string'&&withinPath(fold(answer),cache))return answer;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
165
185
|
// The compiler for one check run {script, part, args}: script null for a
|
|
166
186
|
// check init proposed without one. The run must pass --noEmit and name a
|
|
167
187
|
// config inside the root.
|
package/src/index.mjs
CHANGED
|
@@ -11,6 +11,11 @@ const tracked = root => [...new Set(execFileSync('git',
|
|
|
11
11
|
['ls-files','--cached','--others','--exclude-standard','-z'],
|
|
12
12
|
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
13
13
|
.toString().split('\0').filter(Boolean))];
|
|
14
|
+
// Git-ignored paths at the top of each ignored tree: a directory ends in '/'.
|
|
15
|
+
const ignored = root => [...new Set(execFileSync('git',
|
|
16
|
+
['ls-files','--others','--ignored','--exclude-standard','--directory','-z'],
|
|
17
|
+
{cwd:root,env:{...process.env,GIT_OPTIONAL_LOCKS:'0'},maxBuffer:64*1024*1024})
|
|
18
|
+
.toString().split('\0').filter(Boolean))];
|
|
14
19
|
const xor = (left,right) => { for(let i=0;i<32;i++) left[i]^=right[i]; };
|
|
15
20
|
const token = (file,hash) => createHash('sha256').update(file+'\0'+hash).digest();
|
|
16
21
|
const stablePrefix = glob => {
|
|
@@ -178,12 +183,13 @@ export class InputIndex {
|
|
|
178
183
|
}
|
|
179
184
|
summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
|
|
180
185
|
revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
|
|
181
|
-
|
|
186
|
+
// `only`, when given, limits which checks may take the file.
|
|
187
|
+
record(rel, changeReason=rel+' changed', observedStat=null, only=null) {
|
|
182
188
|
if(!validInputRelative(rel)) {
|
|
183
189
|
this.unavailable='invalid event path'; return false;
|
|
184
190
|
}
|
|
185
191
|
const names=this.candidates(rel), old=this.files.get(rel), matched=[];
|
|
186
|
-
for(const name of names) if(this.matches(this.checks.get(name),rel)) matched.push(name);
|
|
192
|
+
for(const name of names) if((!only||only(name))&&this.matches(this.checks.get(name),rel)) matched.push(name);
|
|
187
193
|
if(!matched.length && old===undefined) return false;
|
|
188
194
|
let fresh,stat,inspection;
|
|
189
195
|
try{stat=observedStat||fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
@@ -198,7 +204,7 @@ export class InputIndex {
|
|
|
198
204
|
// (its package.json stands for them).
|
|
199
205
|
// The second form is for recorded checks only: their recorder
|
|
200
206
|
// classifies a read through the link where it leads.
|
|
201
|
-
const recorded=name=>['node-script-v1','npm-tests-direct-v1'].includes(this.checks.get(name).plan.qualification);
|
|
207
|
+
const recorded=name=>['node-script-v1','npm-tests-direct-v1','sandbox-v1'].includes(this.checks.get(name).plan.qualification);
|
|
202
208
|
const observedDir=installed?physical=>!this.includeInstalled&&
|
|
203
209
|
([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
|
|
204
210
|
!physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
|
|
@@ -267,6 +273,10 @@ export class InputIndex {
|
|
|
267
273
|
this.unavailable=null;
|
|
268
274
|
const trackedStarted=performance.now();
|
|
269
275
|
const names=new Set(this.trackGit?tracked(this.root):[]);
|
|
276
|
+
// A sandboxed check's reads are not recorded, so git-ignored files are its
|
|
277
|
+
// inputs too: every ignored file its patterns take, outside the scan roots
|
|
278
|
+
// (installed trees) and the directories no pattern can take a file from.
|
|
279
|
+
const ignoredTrees=this.trackGit&&[...this.checks.values()].some(row=>row.plan.ignoredInputs)?ignored(this.root):[];
|
|
270
280
|
this.profile.trackedMs+=performance.now()-trackedStarted;
|
|
271
281
|
const enumerationStarted=performance.now();
|
|
272
282
|
// Traverse declared input roots to include ignored/generated inputs. Avoid unrelated
|
|
@@ -310,6 +320,29 @@ export class InputIndex {
|
|
|
310
320
|
}
|
|
311
321
|
visit(base,prefix);
|
|
312
322
|
}
|
|
323
|
+
// Only the checks that take ignored files get them here, as before for the others.
|
|
324
|
+
const ignoring=name=>this.checks.get(name).plan.ignoredInputs===true;
|
|
325
|
+
const takes=rel=>[...this.checks.values()].some(row=>row.plan.ignoredInputs&&this.matches(row,rel));
|
|
326
|
+
// A node_modules that is not installed input is skipped, as above. An
|
|
327
|
+
// ignored link is taken when it leads to a file in the checkout; a link
|
|
328
|
+
// to a directory or out of the checkout is not a file input.
|
|
329
|
+
const linkToFile=rel=>{try{const target=fs.realpathSync(path.join(this.root,rel));
|
|
330
|
+
return fs.statSync(target).isFile()&&target.startsWith(fs.realpathSync(this.root)+path.sep);}catch{return false;}};
|
|
331
|
+
const visitIgnored=rel=>{
|
|
332
|
+
const base=path.posix.basename(rel);
|
|
333
|
+
if(this.prefixes.has(rel)||this.installedPath(rel)&&!this.installedAncestors.has(rel)||base==='.git'||
|
|
334
|
+
base==='node_modules'&&!this.installedPath(rel))return;
|
|
335
|
+
let st;try{st=fs.lstatSync(path.join(this.root,rel),{bigint:true});}
|
|
336
|
+
catch(e){if(e.code==='ENOENT')return;throw e;}
|
|
337
|
+
this.profile.enumeratedEntries++;
|
|
338
|
+
if(st.isSymbolicLink()&&!linkToFile(rel))return;
|
|
339
|
+
if(st.isDirectory()){
|
|
340
|
+
if(!takes(rel+'/.redue-probe'))return;
|
|
341
|
+
for(const ent of fs.readdirSync(path.join(this.root,rel),{withFileTypes:true}))visitIgnored(rel+'/'+ent.name);
|
|
342
|
+
}else if(this.candidates(rel).size){
|
|
343
|
+
const started=performance.now();this.record(rel,'cold reconciliation',st,ignoring);processingMs+=performance.now()-started;scanned++;}
|
|
344
|
+
};
|
|
345
|
+
for(const entry of ignoredTrees)visitIgnored(entry.replace(/\/$/,''));
|
|
313
346
|
this.profile.enumerationMs+=performance.now()-enumerationStarted-processingMs;
|
|
314
347
|
const recordStarted=performance.now();
|
|
315
348
|
for(const rel of names) {
|
package/src/landlock.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# The sandboxed recipe's Linux launcher (src/sandbox.mjs): runs a command
|
|
3
|
+
# under a Landlock ruleset (Linux >= 5.13), with the standard library only.
|
|
4
|
+
# Reads and executes are allowed beneath the --read paths; with --no-tcp
|
|
5
|
+
# (ABI >= 4), every TCP bind and connect is refused, and (ABI >= 6) so is a
|
|
6
|
+
# connection to an abstract Unix socket outside the run. Writes are not
|
|
7
|
+
# handled, so they are not restricted.
|
|
8
|
+
#
|
|
9
|
+
# The launcher stays as the command's parent and a child subreaper: when the
|
|
10
|
+
# command exits, it stops every process the command left running, daemons
|
|
11
|
+
# that started their own session included (they are reparented to it), as a
|
|
12
|
+
# CI job's end does. The command runs in its own process group; a signal the
|
|
13
|
+
# launcher gets is passed to that group once. The launcher then ends as the
|
|
14
|
+
# command did: with its exit status, or by its signal.
|
|
15
|
+
#
|
|
16
|
+
# landlock.py --read /usr --read /tmp ... [--no-tcp] -- cmd args...
|
|
17
|
+
# landlock.py --probe # prints {"abi": N} or {"abi": null, "errno": E}
|
|
18
|
+
import ctypes
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
SYS_CREATE, SYS_ADD, SYS_RESTRICT = 444, 445, 446 # the same on x86_64 and aarch64
|
|
26
|
+
EXECUTE, READ_FILE, READ_DIR = 1 << 0, 1 << 2, 1 << 3
|
|
27
|
+
BIND_TCP, CONNECT_TCP = 1 << 0, 1 << 1
|
|
28
|
+
SCOPE_ABSTRACT_UNIX_SOCKET = 1 << 0
|
|
29
|
+
PR_SET_NO_NEW_PRIVS, PR_SET_CHILD_SUBREAPER = 38, 36
|
|
30
|
+
|
|
31
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
32
|
+
libc.syscall.restype = ctypes.c_long
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RulesetAttr(ctypes.Structure):
|
|
36
|
+
_fields_ = [('fs', ctypes.c_uint64), ('net', ctypes.c_uint64), ('scoped', ctypes.c_uint64)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PathBeneath(ctypes.Structure):
|
|
40
|
+
# Packed, as the kernel's struct is: 12 bytes. Python 3.14 asks for the layout.
|
|
41
|
+
_layout_ = 'ms'
|
|
42
|
+
_pack_ = 1
|
|
43
|
+
_fields_ = [('allowed', ctypes.c_uint64), ('fd', ctypes.c_int32)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def abi():
|
|
47
|
+
v = libc.syscall(SYS_CREATE, None, ctypes.c_size_t(0), ctypes.c_uint32(1))
|
|
48
|
+
return (v, 0) if v >= 0 else (None, ctypes.get_errno())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv):
|
|
52
|
+
if argv == ['--probe']:
|
|
53
|
+
v, err = abi()
|
|
54
|
+
print(json.dumps({'abi': v, 'errno': err}))
|
|
55
|
+
return 0
|
|
56
|
+
reads, no_tcp = [], False
|
|
57
|
+
while argv and argv[0] != '--':
|
|
58
|
+
if argv[0] == '--read':
|
|
59
|
+
reads.append(argv[1]); argv = argv[2:]
|
|
60
|
+
elif argv[0] == '--no-tcp':
|
|
61
|
+
no_tcp = True; argv = argv[1:]
|
|
62
|
+
else:
|
|
63
|
+
sys.exit(f'landlock.py: unknown option {argv[0]}')
|
|
64
|
+
cmd = argv[1:]
|
|
65
|
+
version, err = abi()
|
|
66
|
+
if version is None:
|
|
67
|
+
sys.exit(f'landlock.py: Landlock unavailable (errno {err})')
|
|
68
|
+
handled = EXECUTE | READ_FILE | READ_DIR
|
|
69
|
+
attr = RulesetAttr(handled, (BIND_TCP | CONNECT_TCP) if no_tcp and version >= 4 else 0,
|
|
70
|
+
SCOPE_ABSTRACT_UNIX_SOCKET if no_tcp and version >= 6 else 0)
|
|
71
|
+
size = 24 if attr.scoped else 16 if attr.net else 8
|
|
72
|
+
ruleset = libc.syscall(SYS_CREATE, ctypes.byref(attr), ctypes.c_size_t(size), ctypes.c_uint32(0))
|
|
73
|
+
if ruleset < 0:
|
|
74
|
+
sys.exit(f'landlock.py: create_ruleset errno {ctypes.get_errno()}')
|
|
75
|
+
for place in reads:
|
|
76
|
+
try:
|
|
77
|
+
fd = os.open(place, os.O_PATH | os.O_CLOEXEC)
|
|
78
|
+
except OSError:
|
|
79
|
+
continue
|
|
80
|
+
# A file takes file rights only.
|
|
81
|
+
rights = handled if os.path.isdir(place) else EXECUTE | READ_FILE
|
|
82
|
+
rule = PathBeneath(rights, fd)
|
|
83
|
+
if libc.syscall(SYS_ADD, ctypes.c_int(ruleset), ctypes.c_int(1), ctypes.byref(rule), ctypes.c_uint32(0)) < 0:
|
|
84
|
+
sys.exit(f'landlock.py: add_rule {place} errno {ctypes.get_errno()}')
|
|
85
|
+
os.close(fd)
|
|
86
|
+
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
|
|
87
|
+
sys.exit(f'landlock.py: no_new_privs errno {ctypes.get_errno()}')
|
|
88
|
+
if libc.syscall(SYS_RESTRICT, ctypes.c_int(ruleset), ctypes.c_uint32(0)) < 0:
|
|
89
|
+
sys.exit(f'landlock.py: restrict_self errno {ctypes.get_errno()}')
|
|
90
|
+
os.close(ruleset)
|
|
91
|
+
libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)
|
|
92
|
+
pid = os.fork()
|
|
93
|
+
if pid == 0:
|
|
94
|
+
os.setpgid(0, 0)
|
|
95
|
+
try:
|
|
96
|
+
os.execvp(cmd[0], cmd)
|
|
97
|
+
except OSError as e:
|
|
98
|
+
sys.stderr.write(f'landlock.py: exec {cmd[0]}: {e}\n')
|
|
99
|
+
os._exit(127)
|
|
100
|
+
# Also here, so a signal that comes before the child runs finds its group.
|
|
101
|
+
try:
|
|
102
|
+
os.setpgid(pid, pid)
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
def forward(number, frame):
|
|
106
|
+
try:
|
|
107
|
+
os.killpg(pid, number)
|
|
108
|
+
except (ProcessLookupError, PermissionError):
|
|
109
|
+
pass
|
|
110
|
+
for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
111
|
+
signal.signal(number, forward)
|
|
112
|
+
_, status = os.waitpid(pid, 0)
|
|
113
|
+
stop_left()
|
|
114
|
+
code = os.waitstatus_to_exitcode(status)
|
|
115
|
+
if code >= 0:
|
|
116
|
+
return code
|
|
117
|
+
# Killed by a signal: end the same way, so the caller sees the signal.
|
|
118
|
+
try:
|
|
119
|
+
signal.signal(-code, signal.SIG_DFL)
|
|
120
|
+
except (OSError, ValueError):
|
|
121
|
+
pass
|
|
122
|
+
os.kill(os.getpid(), -code)
|
|
123
|
+
return 128 - code
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def children(me):
|
|
127
|
+
# The kernel's list of this process's children, where it keeps one.
|
|
128
|
+
try:
|
|
129
|
+
found = set()
|
|
130
|
+
for task in os.listdir('/proc/self/task'):
|
|
131
|
+
with open(f'/proc/self/task/{task}/children') as f:
|
|
132
|
+
found.update(int(pid) for pid in f.read().split())
|
|
133
|
+
return list(found)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
# Else every process's parent. The fields after the command name, which
|
|
137
|
+
# may hold spaces or parentheses.
|
|
138
|
+
found = []
|
|
139
|
+
for entry in os.listdir('/proc'):
|
|
140
|
+
if not entry.isdigit():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
with open(f'/proc/{entry}/stat') as f:
|
|
144
|
+
stat = f.read()
|
|
145
|
+
except OSError:
|
|
146
|
+
continue
|
|
147
|
+
if int(stat[stat.rindex(')') + 2:].split()[1]) == me:
|
|
148
|
+
found.append(int(entry))
|
|
149
|
+
return found
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Every process left under the launcher: SIGTERM, then SIGKILL after a second.
|
|
153
|
+
# A process's own children are reparented here when it ends, so this repeats
|
|
154
|
+
# until none is left, for at most three seconds: a process in uninterruptible
|
|
155
|
+
# sleep is named and left.
|
|
156
|
+
def stop_left():
|
|
157
|
+
me, start, number = os.getpid(), time.monotonic(), signal.SIGTERM
|
|
158
|
+
while True:
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
done, _ = os.waitpid(-1, os.WNOHANG)
|
|
162
|
+
except ChildProcessError:
|
|
163
|
+
done = 0
|
|
164
|
+
if done == 0:
|
|
165
|
+
break
|
|
166
|
+
left = children(me)
|
|
167
|
+
if not left:
|
|
168
|
+
return
|
|
169
|
+
if time.monotonic() > start + 3:
|
|
170
|
+
sys.stderr.write(f'landlock.py: processes {left} did not stop\n')
|
|
171
|
+
return
|
|
172
|
+
if time.monotonic() > start + 1:
|
|
173
|
+
number = signal.SIGKILL
|
|
174
|
+
for pid in left:
|
|
175
|
+
try:
|
|
176
|
+
os.kill(pid, number)
|
|
177
|
+
except ProcessLookupError:
|
|
178
|
+
pass
|
|
179
|
+
time.sleep(0.05)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == '__main__':
|
|
183
|
+
sys.exit(main(sys.argv[1:]))
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {directProject,directQualification} from './direct-typescript.mjs';
|
|
2
|
-
import {testProject,testRecipeScript,testCheckInputs,
|
|
3
|
-
import {
|
|
2
|
+
import {testProject,testRecipeScript,testCheckInputs,testsQualification} from './test-direct.mjs';
|
|
3
|
+
import {generalIssue,scriptCheckInputs,scriptProject,scriptQualification} from './node-script.mjs';
|
|
4
|
+
import {sandboxCheckInputs,sandboxKinds,sandboxQualification} from './sandbox.mjs';
|
|
4
5
|
// Init proposes the general recipe for typecheck and lint scripts; test
|
|
5
6
|
// scripts use the direct test recipe (Vitest, Jest).
|
|
6
7
|
const generalKinds=['typecheck','lint'];
|
|
@@ -110,7 +111,7 @@ const proposable=(dir,scripts)=>exists(path.join(dir,'tsconfig.json'))&&!typeche
|
|
|
110
111
|
// The inputs a TypeScript contract adds to its check's config: no exclusions,
|
|
111
112
|
// which would outlive the tsconfig they came from; the plan adds the current ones.
|
|
112
113
|
const contractInputs=contract=>[...contract.configs,...contract.sourcePatterns.filter(p=>!p.startsWith('!'))];
|
|
113
|
-
function sourceLinkIssue(root) {
|
|
114
|
+
export function sourceLinkIssue(root) {
|
|
114
115
|
const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
|
|
115
116
|
let inspected=0;
|
|
116
117
|
while(stack.length){
|
|
@@ -207,22 +208,16 @@ export function workspacePackages(root){
|
|
|
207
208
|
// supported package manager and platform, and a script (with its pre and post
|
|
208
209
|
// scripts) that, read as text, starts only Node programs.
|
|
209
210
|
function generalCheck(root,kind,script){
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
let project;
|
|
214
|
-
try{project=scriptProject(root,script);}catch(e){throw Error(e.code?.replace(/^test-/,'')||e.message);}
|
|
215
|
-
for(const name of ['pre'+script,script,'post'+script]){
|
|
216
|
-
if(typeof project.scripts[name]!=='string')continue;
|
|
217
|
-
const issue=commandLineIssue(project.scripts[name],{cwd:root,searchPath:scriptPath(root,process.env.PATH)});
|
|
218
|
-
if(issue)throw Error(name===script?issue:`${name}: ${issue}`);
|
|
219
|
-
}
|
|
211
|
+
const issue=generalIssue(root,script);
|
|
212
|
+
if(issue)throw Error(issue);
|
|
213
|
+
const project=scriptProject(root,script);
|
|
220
214
|
return {config:{name:kind,script,kind,qualification:scriptQualification,...scriptCheckInputs(project.dirs)},manager:project.manager};
|
|
221
215
|
}
|
|
222
216
|
export function discoverNodeProject(root,{recipe=null}={}) {
|
|
223
|
-
if(recipe&&!['typescript-direct','tests-direct','node-script'].includes(recipe))
|
|
224
|
-
throw Error('unknown recipe; use typescript-direct, tests-direct
|
|
225
|
-
// Without --recipe, init tries the strict recipes first, then the general
|
|
217
|
+
if(recipe&&!['typescript-direct','tests-direct','node-script','sandbox'].includes(recipe))
|
|
218
|
+
throw Error('unknown recipe; use typescript-direct, tests-direct, node-script or sandbox');
|
|
219
|
+
// Without --recipe, init tries the strict recipes first, then the general
|
|
220
|
+
// one, then the sandboxed one.
|
|
226
221
|
const auto=recipe===null;
|
|
227
222
|
root=realObservedPath(root);
|
|
228
223
|
const packageFile=path.join(root,'package.json');
|
|
@@ -281,7 +276,7 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
281
276
|
reason:`explicit local ${project.runner} ${project.version} run of script ${JSON.stringify(chosen.script)}; the whole checkout is its input; npm and lifecycle scripts are not executed`};
|
|
282
277
|
}catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
|
|
283
278
|
}
|
|
284
|
-
if(recipe!=='node-script'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
|
|
279
|
+
if(recipe!=='node-script'&&recipe!=='sandbox'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
|
|
285
280
|
try{
|
|
286
281
|
parts=tscChecks('typecheck',script??null,scripts,run=>{
|
|
287
282
|
const contract=pnpmTypecheckInputs(root,'.',run);
|
|
@@ -294,14 +289,40 @@ export function discoverNodeProject(root,{recipe=null}={}) {
|
|
|
294
289
|
qualification:'pnpm-tsc-v1'};}
|
|
295
290
|
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
296
291
|
}
|
|
292
|
+
// The general recipe's row, when it takes the script.
|
|
293
|
+
let generalIssue=null,generalRow=null;
|
|
297
294
|
if((auto||recipe==='node-script')&&script&&generalKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue){
|
|
298
295
|
try{
|
|
299
296
|
const general=generalCheck(root,kind,script);
|
|
300
|
-
|
|
301
|
-
reason:`general recipe: ${general.manager} runs script ${JSON.stringify(script)} with REDUE's recorder in every Node process; the whole checkout is its input`}
|
|
297
|
+
generalRow={config:general.config,level:'ready',qualification:scriptQualification,
|
|
298
|
+
reason:`general recipe: ${general.manager} runs script ${JSON.stringify(script)} with REDUE's recorder in every Node process; the whole checkout is its input`};
|
|
299
|
+
}catch(e){generalIssue=e.message;classification={...classification,reason:`General recipe unavailable (${e.message}); ${classification.reason}`};}
|
|
300
|
+
}
|
|
301
|
+
// After the strict recipes, a root typecheck or lint script gets the
|
|
302
|
+
// sandboxed recipe, which also covers programs the general recipe cannot
|
|
303
|
+
// record. It is ready only once init has run it both ways (bin/redue.mjs);
|
|
304
|
+
// `fallback` is the row init writes when that shows a difference or the
|
|
305
|
+
// sandbox cannot run here: the general recipe's, when it takes the script.
|
|
306
|
+
// Where the sandbox cannot start at run time, a script the general recipe
|
|
307
|
+
// takes is recorded as it would record it (cli.mjs).
|
|
308
|
+
// A script whose program is not installed gets none, and neither does one
|
|
309
|
+
// that changes files (`--fix`, `--write`): init would run it twice.
|
|
310
|
+
const writes=script&&['pre'+script,script,'post'+script].map(name=>scripts[name]).find(text=>typeof text==='string'&&
|
|
311
|
+
/(?:^|\s)--(?:fix|write|unsafe-fixes)(?=[\s=]|$)/.test(text));
|
|
312
|
+
if(writes)classification={...classification,reason:`Sandboxed recipe unavailable (the script changes files: ${writes.match(/--(?:fix|write|unsafe-fixes)/)[0]}); ${classification.reason}`};
|
|
313
|
+
if((auto||recipe==='sandbox')&&script&&sandboxKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue&&
|
|
314
|
+
!/REDUE cannot find/.test(generalIssue||'')&&!writes){
|
|
315
|
+
try{
|
|
316
|
+
const project=scriptProject(root,script);
|
|
317
|
+
const fallback=generalRow??{config:{...check,...classification.qualification?{qualification:classification.qualification}:{}},...classification};
|
|
318
|
+
checks.push({config:{name:kind,script,kind,qualification:sandboxQualification,...sandboxCheckInputs(project.dirs)},level:'ready',
|
|
319
|
+
qualification:sandboxQualification,fallback,
|
|
320
|
+
reason:`sandboxed recipe: ${project.manager} runs script ${JSON.stringify(script)} inside the OS sandbox${generalIssue?` (the general recipe cannot record it: ${generalIssue})`:
|
|
321
|
+
generalRow?'; where the sandbox cannot run, the general recipe records it':''}; the whole checkout is its input`});
|
|
302
322
|
continue;
|
|
303
|
-
}catch(e){classification={...classification,reason:`
|
|
323
|
+
}catch(e){classification={...classification,reason:`Sandboxed recipe unavailable (${e.code?.replace(/^test-/,'')||e.message}); ${classification.reason}`};}
|
|
304
324
|
}
|
|
325
|
+
if(generalRow){checks.push(generalRow);continue;}
|
|
305
326
|
if(!script&&classification.level!=='ready')continue;
|
|
306
327
|
if(parts&&classification.level==='ready'){
|
|
307
328
|
for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
|
package/src/node-script.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import {pnpmShimTarget} from './pnpm-shims.mjs';
|
|
15
|
-
import {testPackageManager,testWorkspaceDirs,testEnvironment} from './test-direct.mjs';
|
|
15
|
+
import {testPackageManager,testWorkspaceDirs,testEnvironment,testEnvironmentIssue} from './test-direct.mjs';
|
|
16
16
|
|
|
17
17
|
export const scriptQualification='node-script-v1';
|
|
18
18
|
export const scriptInterpretation='node-script@1';
|
|
@@ -358,6 +358,21 @@ export function childProgram({file,args,cwd,searchPath,env=null,script=null},{to
|
|
|
358
358
|
return {needsHeader:false};
|
|
359
359
|
return {issue:`ran ${name}, a program REDUE does not record`};
|
|
360
360
|
}
|
|
361
|
+
// Why the general recipe could not record a root script now, or null: what
|
|
362
|
+
// init checks before it proposes it. A sandboxed check whose sandbox cannot
|
|
363
|
+
// start is recorded this way instead (cli.mjs).
|
|
364
|
+
export function generalIssue(root,script){
|
|
365
|
+
if(!['darwin','linux'].includes(process.platform))return 'the general recipe records runs on macOS and Linux only';
|
|
366
|
+
const environment=testEnvironmentIssue();
|
|
367
|
+
if(environment)return environment;
|
|
368
|
+
let project;try{project=scriptProject(root,script);}catch(e){return e.code?.replace(/^test-/,'')||e.message;}
|
|
369
|
+
for(const name of ['pre'+script,script,'post'+script]){
|
|
370
|
+
if(typeof project.scripts[name]!=='string')continue;
|
|
371
|
+
const issue=commandLineIssue(project.scripts[name],{cwd:root,searchPath:scriptPath(root,process.env.PATH)});
|
|
372
|
+
if(issue)return name===script?issue:`${name}: ${issue}`;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
361
376
|
// What a recorded run needs from the check: the workspace package directories
|
|
362
377
|
// whose node_modules its installed inputs list. The script must still exist.
|
|
363
378
|
export function scriptRunFacts(root,check){
|
package/src/pnpm-inputs.mjs
CHANGED
|
@@ -8,7 +8,6 @@ import path from 'node:path';
|
|
|
8
8
|
import {createRequire} from 'node:module';
|
|
9
9
|
import {execFileSync,spawnSync} from 'node:child_process';
|
|
10
10
|
import micromatch,{patternMatcher} from './glob.mjs';
|
|
11
|
-
import YAML from 'yaml';
|
|
12
11
|
import {resolveLinks} from './installed-inputs.mjs';
|
|
13
12
|
import {withinPath as within,realObservedPath,samePath} from './path-identity.mjs';
|
|
14
13
|
import {timing} from './decision-profile.mjs';
|
|
@@ -18,7 +17,11 @@ import {runConfig,tscRun,tscRuns} from './typescript-invocation.mjs';
|
|
|
18
17
|
const rel=(root,file)=>path.relative(root,file).split(path.sep).join('/');
|
|
19
18
|
const read=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
20
19
|
const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
|
|
20
|
+
// Loaded on first use: the parser takes about 40 ms to load, and every
|
|
21
|
+
// command imports this module.
|
|
22
|
+
let YAML=null;
|
|
21
23
|
export function yaml(file){
|
|
24
|
+
YAML??=createRequire(import.meta.url)('yaml');
|
|
22
25
|
const doc=YAML.parseDocument(fs.readFileSync(file,'utf8'),{uniqueKeys:true});
|
|
23
26
|
if(doc.errors.length)throw Error(`${path.basename(file)} cannot be interpreted safely`);
|
|
24
27
|
return doc.toJS()||{};
|
package/src/presentation.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The recipe that made a receipt, by the qualification it names.
|
|
3
3
|
const recipes={'npm-typescript-direct-v1':'direct TypeScript recipe','npm-tests-direct-v1':'direct test recipe',
|
|
4
4
|
'node-script-v1':'general recipe','pnpm-tsc-v1':'pnpm TypeScript recipe','yarn-workspace-tsc-v1':'Yarn workspace TypeScript recipe',
|
|
5
|
-
'typescript-noemit-v1':'npm TypeScript recipe'};
|
|
5
|
+
'typescript-noemit-v1':'npm TypeScript recipe','sandbox-v1':'sandboxed recipe'};
|
|
6
6
|
export const recipeLabel=recipe=>recipes[recipe]??null;
|
|
7
7
|
export function checkLabel(row) {
|
|
8
8
|
return row.result==='FAIL'?'FAILED':row.freshness;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Python projects for `redue init`: typecheck and lint checks that run mypy,
|
|
2
|
+
// pyright, ty or ruff from the project's .venv under the sandboxed recipe
|
|
3
|
+
// (sandbox.mjs). A tool is proposed when it is installed in .venv/bin. Its
|
|
4
|
+
// arguments are the ones the project runs it with, read from CI workflows,
|
|
5
|
+
// the Makefile, tox, nox, a justfile and shell scripts; without one, the
|
|
6
|
+
// tool's own default (`ruff check`, `ty check`, `pyright`, and `mypy` with
|
|
7
|
+
// its configured files or `.`).
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import {realObservedPath} from './path-identity.mjs';
|
|
11
|
+
import {sandboxCheckInputs,sandboxQualification} from './sandbox.mjs';
|
|
12
|
+
|
|
13
|
+
const read=file=>{try{return fs.readFileSync(file,'utf8');}catch{return '';}};
|
|
14
|
+
const exists=file=>{try{fs.lstatSync(file);return true;}catch{return false;}};
|
|
15
|
+
const executable=file=>{try{fs.accessSync(file,fs.constants.X_OK);return fs.statSync(file).isFile();}catch{return false;}};
|
|
16
|
+
// Each tool, its kind, and the subcommand it runs as a check.
|
|
17
|
+
export const pythonTools=[{tool:'mypy',kind:'typecheck',sub:[]},{tool:'pyright',kind:'typecheck',sub:[]},
|
|
18
|
+
{tool:'ty',kind:'typecheck',sub:['check']},{tool:'ruff',kind:'lint',sub:['check']}];
|
|
19
|
+
// The text of a TOML or INI section, up to the next section header.
|
|
20
|
+
const section=(text,header)=>{const at=text.split(/\r?\n/).findIndex(line=>line.trim()===header);
|
|
21
|
+
if(at<0)return null;const rest=text.split(/\r?\n/).slice(at+1);const end=rest.findIndex(line=>/^\s*\[/.test(line));
|
|
22
|
+
return (end<0?rest:rest.slice(0,end)).join('\n');};
|
|
23
|
+
// Ways a project starts a tool: through uv, poetry or pdm, `python -m`, or from .venv/bin.
|
|
24
|
+
const wrappers=[/^uv run(?: --(?:frozen|locked|no-sync|offline|quiet|all-extras|all-groups))*(?: --(?:group|extra|with|python|package) [^ ]+)* /,
|
|
25
|
+
/^poetry run /,/^pdm run /,/^python3?(?:\.\d+)? -m /,/^(?:\.\/)?\.venv\/bin\//,
|
|
26
|
+
// A variable that names the environment's bin directory: `${PREFIX}mypy`.
|
|
27
|
+
/^\$\{?[A-Za-z_]+\}?(?=[A-Za-z])/];
|
|
28
|
+
// A tool's arguments in one line of a script or workflow, or null. The line
|
|
29
|
+
// must run the tool and its subcommand plainly; arguments with shell syntax,
|
|
30
|
+
// or ones that change files or install packages, are not taken.
|
|
31
|
+
export function toolArgs(line,{tool,sub}){
|
|
32
|
+
let text=line.trim().replace(/^-\s+/,'').replace(/^run:\s*/,'').replace(/^[@-]+/,'');
|
|
33
|
+
text=text.split(/\s(?:&&|\|\||;|\|)\s/)[0].replace(/\s+/g,' ').trim();
|
|
34
|
+
for(let changed=true;changed;){changed=false;for(const wrapper of wrappers)if(wrapper.test(text)){text=text.replace(wrapper,'');changed=true;}}
|
|
35
|
+
const words=text.split(' ');
|
|
36
|
+
if(words[0]!==tool||JSON.stringify(words.slice(1,1+sub.length))!==JSON.stringify(sub))return null;
|
|
37
|
+
const args=words.slice(1+sub.length);
|
|
38
|
+
if(args.some(arg=>!/^[A-Za-z0-9_./:=,@+%-]+$/.test(arg)||/^--(?:fix|unsafe-fixes|write|install-types|watch)(?:=|$)/.test(arg)||arg.startsWith('..')||arg.startsWith('/')))return null;
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
// The places a project writes how it runs its tools, in order of preference.
|
|
42
|
+
function scriptTexts(root){
|
|
43
|
+
const texts=[];
|
|
44
|
+
const workflows=path.join(root,'.github','workflows');
|
|
45
|
+
let files=[];try{files=fs.readdirSync(workflows).filter(file=>/\.ya?ml$/.test(file)).sort();}catch{}
|
|
46
|
+
for(const file of files)texts.push(read(path.join(workflows,file)));
|
|
47
|
+
for(const file of ['Makefile','makefile','justfile','tox.ini','noxfile.py'])texts.push(read(path.join(root,file)));
|
|
48
|
+
// Shell scripts: *.sh, or a file whose #! line runs a shell (scripts/check).
|
|
49
|
+
for(const dir of ['scripts','script','bin','ci']){let names=[];try{names=fs.readdirSync(path.join(root,dir)).sort();}catch{}
|
|
50
|
+
for(const name of names){const text=read(path.join(root,dir,name)).slice(0,65536);
|
|
51
|
+
if(name.endsWith('.sh')||/^#!\S*(?:\/|env\s+)(?:ba|z|da)?sh\b/.test(text))texts.push(text);}}
|
|
52
|
+
return texts;
|
|
53
|
+
}
|
|
54
|
+
// The arguments the project runs `tool` with, or its default.
|
|
55
|
+
export function projectArgs(root,spec,texts=scriptTexts(root)){
|
|
56
|
+
for(const text of texts)for(const line of text.split(/\r?\n/)){
|
|
57
|
+
if(/^\s*#/.test(line))continue;
|
|
58
|
+
const args=toolArgs(line,spec);
|
|
59
|
+
if(args)return {args,source:'project'};
|
|
60
|
+
}
|
|
61
|
+
if(spec.tool!=='mypy')return {args:[],source:'default'};
|
|
62
|
+
// mypy without targets needs `files` in its configuration.
|
|
63
|
+
const configured=[section(read(path.join(root,'pyproject.toml')),'[tool.mypy]'),section(read(path.join(root,'mypy.ini')),'[mypy]'),
|
|
64
|
+
section(read(path.join(root,'.mypy.ini')),'[mypy]'),section(read(path.join(root,'setup.cfg')),'[mypy]')].some(text=>text&&/^\s*files\s*=/m.test(text));
|
|
65
|
+
return {args:configured?[]:['.'],source:'default'};
|
|
66
|
+
}
|
|
67
|
+
// The Python checks `init` proposes, or null when the root is not a Python
|
|
68
|
+
// project. Each row is ready only once init has run it both ways (bin/redue.mjs).
|
|
69
|
+
export function discoverPythonProject(root){
|
|
70
|
+
root=realObservedPath(root);
|
|
71
|
+
if(!['pyproject.toml','setup.cfg','setup.py','requirements.txt'].some(file=>exists(path.join(root,file))))return null;
|
|
72
|
+
const checks=[],skipped=[];
|
|
73
|
+
if(!exists(path.join(root,'.venv','pyvenv.cfg'))){
|
|
74
|
+
skipped.push({kind:'python',reason:'no .venv in the project root; REDUE runs Python tools from the project\'s .venv (uv sync, or Poetry with virtualenvs.in-project)'});
|
|
75
|
+
return {root,checks,skipped};
|
|
76
|
+
}
|
|
77
|
+
const texts=scriptTexts(root);
|
|
78
|
+
const found=pythonTools.filter(spec=>executable(path.join(root,'.venv','bin',spec.tool)));
|
|
79
|
+
for(const spec of found){
|
|
80
|
+
const {args,source}=projectArgs(root,spec,texts);
|
|
81
|
+
const several=found.filter(other=>other.kind===spec.kind).length>1;
|
|
82
|
+
checks.push({config:{name:several?`${spec.kind}:${spec.tool}`:spec.kind,kind:spec.kind,qualification:sandboxQualification,
|
|
83
|
+
command:[`.venv/bin/${spec.tool}`,...spec.sub,...args],...sandboxCheckInputs()},level:'ready',qualification:sandboxQualification,
|
|
84
|
+
reason:`sandboxed recipe: ${[spec.tool,...spec.sub,...args].join(' ')} from .venv${source==='project'?' as the project runs it':''}; the whole checkout is its input`});
|
|
85
|
+
}
|
|
86
|
+
return {root,checks,skipped};
|
|
87
|
+
}
|