@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +491 -0
- package/README.md +107 -2
- package/bin/redue.mjs +132 -50
- package/docs/README.md +2 -1
- package/docs/user/agents.md +299 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +209 -0
- package/docs/user/getting-started.md +23 -2
- package/docs/user/json.md +2 -0
- package/docs/user/node-script.md +154 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/docs/user/supported-projects.md +56 -23
- package/docs/user/tests-direct.md +132 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +388 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +46 -8
- package/src/claude-hook.mjs +449 -0
- package/src/cli.mjs +263 -40
- package/src/daemon.mjs +433 -94
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +10 -0
- package/src/declared-plan.mjs +29 -6
- package/src/direct-run-certificate.mjs +252 -0
- package/src/direct-typescript-probe.mjs +24 -0
- package/src/direct-typescript.mjs +185 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +72 -19
- package/src/installed-inputs.mjs +7 -1
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +172 -53
- package/src/node-script.mjs +364 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +114 -54
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +9 -1
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/run-recorder.cjs +91 -0
- package/src/state-store.mjs +37 -1
- package/src/test-direct.mjs +560 -0
- package/src/test-recorder.cjs +406 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +108 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {createHash} from 'node:crypto';
|
|
|
5
5
|
import micromatch from './glob.mjs';
|
|
6
6
|
import {sha} from './plan.mjs';
|
|
7
7
|
import {resolveLinks} from './installed-inputs.mjs';
|
|
8
|
-
import {validInputRelative} from './path-identity.mjs';
|
|
8
|
+
import {validInputRelative,identityFields,racyUntil} from './path-identity.mjs';
|
|
9
9
|
|
|
10
10
|
const tracked = root => [...new Set(execFileSync('git',
|
|
11
11
|
['ls-files','--cached','--others','--exclude-standard','-z'],
|
|
@@ -35,37 +35,52 @@ const pathPrefixes = rel => {
|
|
|
35
35
|
return prefixes;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// An installed file is observed by identity, not content: every write changes
|
|
39
|
+
// ctime, so its entry changes without reading the whole installation. The
|
|
40
|
+
// compiler's own reads are content-checked by its probe and certificate. A file
|
|
41
|
+
// changed within the racy window could still be rewritten in the same timestamp
|
|
42
|
+
// tick at the same size; such files are tracked in `racy`, and the daemon waits
|
|
43
|
+
// the window out and records them again before it writes a run checkpoint.
|
|
44
|
+
const identity=st=>identityFields.map(key=>String(st[key])).join(':');
|
|
45
|
+
// An installed link to a directory (pnpm links every package this way) is
|
|
46
|
+
// observed by its link chain alone, when `observedDir` says the directory it
|
|
47
|
+
// resolves to is itself observed installed: its own entries carry its contents.
|
|
38
48
|
function fileHash(root, rel,installed=false,before=fs.lstatSync(path.join(root,rel),
|
|
39
|
-
{bigint:true})) {
|
|
49
|
+
{bigint:true}),observedDir=null) {
|
|
40
50
|
const file=path.join(root, rel);
|
|
41
|
-
let value,references=[],bytes=Number(before.size);
|
|
51
|
+
let value,references=[],bytes=installed?0:Number(before.size),racy=0;
|
|
42
52
|
if(before.isSymbolicLink()){
|
|
43
53
|
const resolution=resolveLinks(file,root,[],true);
|
|
44
54
|
if(!resolution.physical)throw Error(`symlink input target observation unavailable: ${rel}: ${resolution.reason}`);
|
|
45
55
|
const target=fs.statSync(resolution.physical,{bigint:true});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
const physical=path.relative(root,resolution.physical).split(path.sep).join('/');
|
|
57
|
+
const directory=target.isDirectory()&&installed&&observedDir?.(physical);
|
|
58
|
+
if(!target.isFile()&&!directory)throw Error(`directory input symlink unsupported: ${rel} -> ${physical}`);
|
|
59
|
+
if(directory)value='dirlink:'+JSON.stringify(resolution.links)+':'+physical;
|
|
60
|
+
else{
|
|
61
|
+
if(installed)racy=racyUntil(target);else bytes=Number(target.size);
|
|
62
|
+
value='link:'+JSON.stringify(resolution.links)+':'+(installed?'stat:'+identity(target):
|
|
63
|
+
target.mode+':'+hashRegular(resolution.physical).hash);
|
|
64
|
+
}
|
|
50
65
|
references=[...new Set([...resolution.links.map(link=>path.relative(root,link.path)
|
|
51
66
|
.split(path.sep).join('/')),path.relative(root,resolution.physical)
|
|
52
67
|
.split(path.sep).join('/')])];
|
|
53
|
-
|
|
54
|
-
const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
// A directory's own timestamps follow its entries, which are observed separately.
|
|
69
|
+
if(!directory){const targetAfter=fs.statSync(resolution.physical,{bigint:true});
|
|
70
|
+
for(const key of identityFields)
|
|
71
|
+
if(target[key]!==targetAfter[key])throw Error(`input changed during inspection: ${rel}`);}
|
|
57
72
|
const resolutionAfter=resolveLinks(file,root,[],true);
|
|
58
73
|
if(resolutionAfter.physical!==resolution.physical||
|
|
59
74
|
JSON.stringify(resolutionAfter.links)!==JSON.stringify(resolution.links))
|
|
60
75
|
throw Error(`input link changed during inspection: ${rel}`);
|
|
61
76
|
}
|
|
62
|
-
else if(before.isFile())
|
|
63
|
-
|
|
77
|
+
else if(before.isFile()&&installed){value='stat:'+identity(before);racy=racyUntil(before);}
|
|
78
|
+
else if(before.isFile()) value='file:'+before.mode+':'+hashRegular(file).hash;
|
|
64
79
|
else throw Error(`non-file input: ${rel}`);
|
|
65
80
|
const after=fs.lstatSync(file,{bigint:true});
|
|
66
|
-
for(const key of
|
|
81
|
+
for(const key of identityFields)
|
|
67
82
|
if(before[key]!==after[key]) throw Error(`input changed during inspection: ${rel}`);
|
|
68
|
-
return {hash:sha(value),references,bytes};
|
|
83
|
+
return {hash:sha(value),references,bytes,racy};
|
|
69
84
|
}
|
|
70
85
|
const readBuffer=Buffer.allocUnsafe(65536);
|
|
71
86
|
function hashRegular(file) {
|
|
@@ -81,6 +96,7 @@ export class InputIndex {
|
|
|
81
96
|
const matcherStarted=performance.now();
|
|
82
97
|
this.bundle=bundle; this.root=bundle.root; this.files=new Map(); this.members=new Map();
|
|
83
98
|
this.symlinkRefs=new Map();this.aliasDependencies=new Map();
|
|
99
|
+
this.racy=new Map();
|
|
84
100
|
this.trackGit=options.trackGit!==false;
|
|
85
101
|
this.installedRoots=[...new Set(Object.values(bundle.plans).flatMap(plan=>
|
|
86
102
|
plan.installedPhysicalRoots||[]))];
|
|
@@ -149,6 +165,17 @@ export class InputIndex {
|
|
|
149
165
|
}
|
|
150
166
|
return {files,bytes,durationMs:performance.now()-started};
|
|
151
167
|
}
|
|
168
|
+
// When the racy window of the named checks' installed members ends (0: none).
|
|
169
|
+
racyUntil(names=null) {
|
|
170
|
+
let until=0;
|
|
171
|
+
for(const [rel,time] of this.racy)
|
|
172
|
+
if(!names||[...this.members.get(rel)||[]].some(name=>names.includes(name)))until=Math.max(until,time);
|
|
173
|
+
return until;
|
|
174
|
+
}
|
|
175
|
+
// Records again every installed file whose racy window has passed.
|
|
176
|
+
settleRacy() {
|
|
177
|
+
for(const [rel,time] of [...this.racy])if(time<=Date.now())this.record(rel,rel+' changed');
|
|
178
|
+
}
|
|
152
179
|
summary(name) { const row=this.checks.get(name); return {fingerprint:this.fingerprint(name),
|
|
153
180
|
revision:row.revision,planId:row.plan.id,files:row.files.size,probeAt:row.probeAt}; }
|
|
154
181
|
record(rel, changeReason=rel+' changed', observedStat=null) {
|
|
@@ -163,7 +190,20 @@ export class InputIndex {
|
|
|
163
190
|
catch(e){if(e.code!=='ENOENT'){this.unavailable=String(e);return false;}}
|
|
164
191
|
if(stat&&(stat.isFile()||stat.isSymbolicLink())) {
|
|
165
192
|
const installed=this.installedPath(rel);
|
|
166
|
-
try{const started=performance.now();
|
|
193
|
+
try{const started=performance.now();
|
|
194
|
+
// A linked directory counts as observed only strictly inside an
|
|
195
|
+
// installed root, or as a checkout directory outside node_modules (a
|
|
196
|
+
// workspace package the package manager links), and only if every
|
|
197
|
+
// check matching the link also observes the directory's own files
|
|
198
|
+
// (its package.json stands for them).
|
|
199
|
+
// The second form is for recorded checks only: their recorder
|
|
200
|
+
// 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);
|
|
202
|
+
const observedDir=installed?physical=>!this.includeInstalled&&
|
|
203
|
+
([...this.installedRootSet].some(root=>physical.startsWith(root+'/'))||
|
|
204
|
+
!physical.split('/').includes('node_modules')&&!this.installedPath(physical+'/package.json')&&matched.every(recorded))&&
|
|
205
|
+
matched.every(name=>this.matches(this.checks.get(name),physical+'/package.json')):null;
|
|
206
|
+
inspection=fileHash(this.root,rel,installed,stat,observedDir);
|
|
167
207
|
fresh=inspection.hash;
|
|
168
208
|
this.profile.hashingMs+=performance.now()-started;
|
|
169
209
|
this.rehashedFiles++;this.rehashedBytes+=inspection.bytes;}
|
|
@@ -182,6 +222,7 @@ export class InputIndex {
|
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
224
|
const oldNames=this.members.get(rel)||new Set(), newNames=new Set(fresh===undefined?[]:matched);
|
|
225
|
+
if(inspection?.racy>Date.now()&&newNames.size)this.racy.set(rel,inspection.racy);else this.racy.delete(rel);
|
|
185
226
|
if(old===fresh && oldNames.size===newNames.size && [...oldNames].every(n=>newNames.has(n))) return false;
|
|
186
227
|
for(const name of new Set([...oldNames,...newNames])) {
|
|
187
228
|
const row=this.checks.get(name);
|
|
@@ -283,9 +324,10 @@ export class InputIndex {
|
|
|
283
324
|
const full=path.join(this.root,rel);
|
|
284
325
|
let subtree=false;
|
|
285
326
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if(fs.
|
|
327
|
+
// A link is an entry of its own: record() accepts an observed installed
|
|
328
|
+
// directory link and refuses any other.
|
|
329
|
+
if(fs.lstatSync(full).isSymbolicLink())this.record(rel);
|
|
330
|
+
else if(fs.statSync(full).isDirectory()) {this.scanSubtree(rel);subtree=true;}
|
|
289
331
|
else this.record(rel);
|
|
290
332
|
} catch(e) {
|
|
291
333
|
if(e.code==='ENOENT') {
|
|
@@ -304,3 +346,14 @@ export class InputIndex {
|
|
|
304
346
|
return out;
|
|
305
347
|
}
|
|
306
348
|
}
|
|
349
|
+
|
|
350
|
+
// The index state a worker sends and a restart retains; hydrate() in daemon.mjs
|
|
351
|
+
// reads it back.
|
|
352
|
+
export function dehydrateIndex(value) {
|
|
353
|
+
return {files:[...value.files],
|
|
354
|
+
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
355
|
+
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
356
|
+
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
357
|
+
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
358
|
+
rehashedBytes:value.rehashedBytes,profile:value.profile,racy:[...value.racy]};
|
|
359
|
+
}
|
package/src/installed-inputs.mjs
CHANGED
|
@@ -84,6 +84,10 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
|
|
|
84
84
|
if(!resolved.physical){
|
|
85
85
|
if(resolved.reason==='resolved target missing'&&!resolved.links.length){
|
|
86
86
|
internal.add(pattern);continue;}
|
|
87
|
+
// An exclusion of a path that does not exist yet, through links (a runner
|
|
88
|
+
// cache inside a linked package): exclude it where the links lead.
|
|
89
|
+
if(negative&&resolved.reason==='resolved target missing'&&resolved.prospective&&within(resolved.prospective,root)){
|
|
90
|
+
internal.add('!'+posix(path.relative(root,resolved.prospective))+(suffix?'/'+suffix:''));continue;}
|
|
87
91
|
result.unresolved.push(`${prefix}: ${resolved.reason}`);continue;
|
|
88
92
|
}
|
|
89
93
|
const boundary=within(resolved.physical,root)?root:
|
|
@@ -99,7 +103,9 @@ export function resolveDeclaredInstalled(root,patterns,allowed=[]) {
|
|
|
99
103
|
else {if(!external.has(boundary))external.set(boundary,new Set());
|
|
100
104
|
external.get(boundary).add(mapped);}
|
|
101
105
|
if(!negative)roots.add(resolved.physical);
|
|
102
|
-
|
|
106
|
+
// An exclusion maps to the same pattern whether or not its path exists, so
|
|
107
|
+
// the plan stays the same when a run creates it (a runner cache).
|
|
108
|
+
if(!negative)result.mappings.push({logical:pattern,physical:resolved.physical,root:boundary,
|
|
103
109
|
links:resolved.links.map(link=>link.path)});
|
|
104
110
|
}
|
|
105
111
|
result.internalPatterns=[...internal].sort();
|
package/src/linux-inotify.py
CHANGED
|
@@ -73,18 +73,35 @@ def main(root):
|
|
|
73
73
|
file_paths = {}
|
|
74
74
|
intentional_removals = set()
|
|
75
75
|
|
|
76
|
+
# No input lies in a .git directory (config refuses such patterns and the
|
|
77
|
+
# index skips them), so none is watched: git creates object directories
|
|
78
|
+
# there on most commits, and each would be an observation gap.
|
|
79
|
+
def in_git(target):
|
|
80
|
+
return '.git' in os.path.relpath(target, root).split(os.sep)
|
|
81
|
+
|
|
76
82
|
def add_tree(folder):
|
|
77
83
|
stack = [folder]
|
|
78
84
|
while stack:
|
|
79
85
|
current = stack.pop()
|
|
86
|
+
if in_git(current):
|
|
87
|
+
continue
|
|
80
88
|
wd = libc.inotify_add_watch(fd, os.fsencode(current), MASK)
|
|
81
89
|
if wd < 0:
|
|
82
|
-
|
|
90
|
+
error = ctypes.get_errno()
|
|
91
|
+
# A new directory deleted before its watch: nothing to watch,
|
|
92
|
+
# and its parent reports the deletion.
|
|
93
|
+
if current != root and error in (errno.ENOENT, errno.ENOTDIR):
|
|
94
|
+
continue
|
|
95
|
+
raise OSError(error, 'inotify_add_watch failed')
|
|
83
96
|
watches[wd] = current
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
try:
|
|
98
|
+
with os.scandir(current) as entries:
|
|
99
|
+
for entry in entries:
|
|
100
|
+
if entry.is_dir(follow_symlinks=False):
|
|
101
|
+
stack.append(entry.path)
|
|
102
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
103
|
+
if current == root:
|
|
104
|
+
raise
|
|
88
105
|
|
|
89
106
|
def drain():
|
|
90
107
|
while True:
|
|
@@ -119,6 +136,15 @@ def main(root):
|
|
|
119
136
|
emit('gap', reason='inotify_watch_unknown')
|
|
120
137
|
return False
|
|
121
138
|
target = os.path.join(parent, os.fsdecode(raw)) if raw else parent
|
|
139
|
+
if in_git(target):
|
|
140
|
+
continue
|
|
141
|
+
if mask & IN_DELETE_SELF and parent != root:
|
|
142
|
+
# A watched directory was deleted: its IN_IGNORED follows
|
|
143
|
+
# and is expected. The engine rescans the path.
|
|
144
|
+
del watches[wd]
|
|
145
|
+
intentional_removals.add(wd)
|
|
146
|
+
emit('event', type='delete', path=parent)
|
|
147
|
+
continue
|
|
122
148
|
if mask & (IN_UNMOUNT | IN_DELETE_SELF | IN_MOVE_SELF | IN_IGNORED):
|
|
123
149
|
emit('gap', reason='inotify_watch_lost')
|
|
124
150
|
return False
|
|
@@ -128,11 +154,14 @@ def main(root):
|
|
|
128
154
|
except OSError as error:
|
|
129
155
|
emit('gap', reason='inotify_watch_add_failed', errno=error.errno)
|
|
130
156
|
return False
|
|
131
|
-
# Contents can appear before
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
157
|
+
# Contents can appear before the new directory has its
|
|
158
|
+
# watch. The watches exist now, so later changes raise
|
|
159
|
+
# events; the engine rescans the directory on this event
|
|
160
|
+
# and finds what came before.
|
|
161
|
+
emit('event', type='create', path=target)
|
|
162
|
+
continue
|
|
163
|
+
# A moved-away directory keeps watches under its old paths.
|
|
164
|
+
if (mask & IN_ISDIR) and (mask & IN_MOVED_FROM):
|
|
136
165
|
emit('gap', reason='inotify_directory_removed')
|
|
137
166
|
return False
|
|
138
167
|
event = ('delete' if mask & (IN_DELETE | IN_MOVED_FROM) else
|
package/src/node-onboarding.mjs
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import {directProject,directQualification} from './direct-typescript.mjs';
|
|
2
|
+
import {testProject,testRecipeScript,testCheckInputs,testEnvironmentIssue,testsQualification} from './test-direct.mjs';
|
|
3
|
+
import {commandLineIssue,scriptCheckInputs,scriptPath,scriptProject,scriptQualification} from './node-script.mjs';
|
|
4
|
+
// Init proposes the general recipe for typecheck and lint scripts; test
|
|
5
|
+
// scripts use the direct test recipe (Vitest, Jest).
|
|
6
|
+
const generalKinds=['typecheck','lint'];
|
|
7
|
+
const perPackageLimit=50;
|
|
1
8
|
import fs from 'node:fs';
|
|
2
9
|
import path from 'node:path';
|
|
3
10
|
import {spawnSync} from 'node:child_process';
|
|
4
|
-
import micromatch from './glob.mjs';
|
|
5
11
|
import {yarnWorkspaceTypecheckInputs} from './yarn-workspace-inputs.mjs';
|
|
6
12
|
import {pnpmInstallInfo,pnpmProjectInfo,pnpmTypecheckInputs} from './pnpm-inputs.mjs';
|
|
7
13
|
import {realObservedPath} from './path-identity.mjs';
|
|
8
14
|
import {findExecutable} from './executable-lookup.mjs';
|
|
15
|
+
import {workspaceManifests} from './workspace-manifests.mjs';
|
|
16
|
+
import {partName,tscRuns} from './typescript-invocation.mjs';
|
|
9
17
|
|
|
10
18
|
const exists=file=>{try{fs.accessSync(file);return true;}catch{return false;}};
|
|
11
19
|
const readJson=file=>JSON.parse(fs.readFileSync(file,'utf8'));
|
|
12
20
|
const scriptNames={
|
|
13
|
-
typecheck:['typecheck','type-check','check:types','types:check'],
|
|
21
|
+
typecheck:['typecheck','type-check','check:types','types:check','check-types','tsc','types','lint:types'],
|
|
14
22
|
test:['test','test:unit','test:check'],
|
|
15
23
|
lint:['lint','lint:check'],
|
|
16
24
|
build:['build'],
|
|
@@ -80,6 +88,28 @@ function manager(root,pkg) {
|
|
|
80
88
|
return {name,version,layout:'node-modules/classic',issue:null,lockfiles:locks};
|
|
81
89
|
}
|
|
82
90
|
const exactTypeScript=script=>script.trim()==='tsc --noEmit';
|
|
91
|
+
// A script of tsc runs that each pass --noEmit (typescript-invocation.mjs).
|
|
92
|
+
const noEmitRuns=script=>Boolean(tscRuns(script)?.every(run=>run.noEmit));
|
|
93
|
+
// Ready checks for a typecheck, one per tsc run: the script's runs (a chain
|
|
94
|
+
// gives NAME.1 ... NAME.n), or with no script one proposed `tsc --noEmit`.
|
|
95
|
+
// `make(run)` returns a run's command, cwd and inputs, or throws why its
|
|
96
|
+
// contract does not hold.
|
|
97
|
+
function tscChecks(name,script,scripts,make){
|
|
98
|
+
const runs=script===null?[{args:['--noEmit']}]:tscRuns(scripts[script]);
|
|
99
|
+
if(!runs)throw Error('the script is not tsc runs in a supported form');
|
|
100
|
+
return runs.map(({args},i)=>({name:partName(name,i+1,runs.length),
|
|
101
|
+
...(script===null?{}:{script}),...make({script,args,count:runs.length})}));
|
|
102
|
+
}
|
|
103
|
+
// A proposed `tsc --noEmit` only where nothing typechecks already: a script
|
|
104
|
+
// that runs any TypeScript checker in another form (tsc -b, vue-tsc,
|
|
105
|
+
// svelte-check, tsgo, also quoted or wrapped by concurrently or run-p) is the
|
|
106
|
+
// project's own typecheck, which plain tsc would not match.
|
|
107
|
+
const typeChecker=/(?:^|[^\w-])(?:tsc|vue-tsc|tsgo|svelte-check|tsc-watch)(?=$|[^\w-])|\bastro check\b|\bnuxi typecheck\b/;
|
|
108
|
+
const typechecks=scripts=>Object.values(scripts||{}).some(text=>typeof text==='string'&&typeChecker.test(text));
|
|
109
|
+
const proposable=(dir,scripts)=>exists(path.join(dir,'tsconfig.json'))&&!typechecks(scripts);
|
|
110
|
+
// The inputs a TypeScript contract adds to its check's config: no exclusions,
|
|
111
|
+
// which would outlive the tsconfig they came from; the plan adds the current ones.
|
|
112
|
+
const contractInputs=contract=>[...contract.configs,...contract.sourcePatterns.filter(p=>!p.startsWith('!'))];
|
|
83
113
|
function sourceLinkIssue(root) {
|
|
84
114
|
const stack=likelyDirs.filter(name=>exists(path.join(root,name)));
|
|
85
115
|
let inspected=0;
|
|
@@ -105,7 +135,8 @@ function sourceInputs(root,kind,lockfiles) {
|
|
|
105
135
|
'!node_modules/**','!.git/**'])];
|
|
106
136
|
const dirs=likelyDirs.filter(name=>exists(path.join(root,name)))
|
|
107
137
|
.map(name=>`${name}/**`);
|
|
108
|
-
|
|
138
|
+
// A workspace package's node_modules under them is installed, not source.
|
|
139
|
+
return [...new Set([...files,...(dirs.length?[...dirs,'!**/node_modules/**']:['package.json'])])];
|
|
109
140
|
}
|
|
110
141
|
function classify(kind,scriptName,script,pkg,managerInfo,root) {
|
|
111
142
|
const value=script.trim();
|
|
@@ -116,7 +147,7 @@ function classify(kind,scriptName,script,pkg,managerInfo,root) {
|
|
|
116
147
|
managerInfo.name==='npm'&&
|
|
117
148
|
exists(path.join(root,'tsconfig.json'))&&
|
|
118
149
|
exists(path.join(root,'node_modules/typescript/package.json')))
|
|
119
|
-
return {level:'
|
|
150
|
+
return {level:'recording',reason:'npm launcher uses unobserved remote/cache/time inputs; execution can be recorded, but cannot be reused',
|
|
120
151
|
qualification:'typescript-noemit-v1'};
|
|
121
152
|
if(kind==='typecheck')return {level:'recording',reason:
|
|
122
153
|
'TypeScript invocation, installation, or configuration is outside the automatic noEmit contract'};
|
|
@@ -130,36 +161,69 @@ function classify(kind,scriptName,script,pkg,managerInfo,root) {
|
|
|
130
161
|
'Vite detected; build environment and generated inputs need review':
|
|
131
162
|
'build script detected; generated and runtime inputs need review'};
|
|
132
163
|
}
|
|
164
|
+
// Typecheck scripts named under a typecheck name (`typecheck:test`,
|
|
165
|
+
// `tsc:build`), watch scripts aside.
|
|
166
|
+
const typecheckFamily=/^(?:typecheck|type-check|check-types|check:types|types:check|tsc):(?!.*watch)/;
|
|
133
167
|
function selectScript(kind,names,scripts) {
|
|
134
168
|
const preferred=names.find(name=>typeof scripts[name]==='string'&&
|
|
135
169
|
scripts[name].trim()&&!/^echo\s+["']?Error: no test specified/.test(scripts[name]));
|
|
136
170
|
if(preferred)return {script:preferred};
|
|
171
|
+
const family=kind==='typecheck'?Object.keys(scripts).filter(name=>typecheckFamily.test(name)&&
|
|
172
|
+
typeof scripts[name]==='string'&&scripts[name].trim()&&!/(?:^|\s)(?:--watch|-w)(?:\s|$)/.test(scripts[name])):[];
|
|
173
|
+
if(family.length===1)return {script:family[0]};
|
|
137
174
|
const candidates=Object.keys(scripts).filter(name=>{
|
|
138
175
|
const value=scripts[name]?.trim();
|
|
139
176
|
if(typeof value!=='string')return false;
|
|
140
|
-
if(kind==='typecheck')return
|
|
177
|
+
if(kind==='typecheck')return noEmitRuns(value);
|
|
141
178
|
if(kind==='test')return /^(?:vitest|jest)(?:\s|$)/.test(value);
|
|
142
179
|
if(kind==='lint')return /^eslint(?:\s|$)/.test(value);
|
|
143
180
|
return /^vite\s+build(?:\s|$)/.test(value);
|
|
144
181
|
});
|
|
182
|
+
// A lone plain `tsc --noEmit` wins over other tsc shapes beside it (a
|
|
183
|
+
// project's `typescript` next to `typescript:tests:raw`).
|
|
184
|
+
const exact=kind==='typecheck'?candidates.filter(name=>exactTypeScript(scripts[name])):[];
|
|
185
|
+
if(exact.length===1)return {script:exact[0]};
|
|
145
186
|
return candidates.length===1?{script:candidates[0]}:
|
|
146
|
-
candidates.length>1?{ambiguous:candidates}:{};
|
|
187
|
+
candidates.length>1?{ambiguous:candidates}:family.length>1?{ambiguous:family}:{};
|
|
147
188
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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))}));
|
|
189
|
+
// A pnpm workspace package's TypeScript run, as its check's command, cwd and inputs.
|
|
190
|
+
function pnpmCheck(root,name,dir,manifest,run){
|
|
191
|
+
const contract=pnpmTypecheckInputs(root,name,run);
|
|
192
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
193
|
+
return {cwd:dir,command:['@node',`@typescript-bin:${dir}`,...run.args],
|
|
194
|
+
inputs:[...new Set(['package.json','pnpm-lock.yaml','pnpm-workspace.yaml',manifest,
|
|
195
|
+
...contractInputs(contract)])]};
|
|
161
196
|
}
|
|
162
|
-
|
|
197
|
+
// Workspace packages as init finds them, with their scripts; [] without workspaces.
|
|
198
|
+
export function workspacePackages(root){
|
|
199
|
+
root=realObservedPath(root);
|
|
200
|
+
const pkg=readJson(path.join(root,'package.json')),pm=manager(root,pkg);
|
|
201
|
+
const patterns=pm.name==='pnpm'?pm.workspacePatterns:Array.isArray(pkg.workspaces)?pkg.workspaces:pkg.workspaces?.packages;
|
|
202
|
+
if(!Array.isArray(patterns)||!patterns.length)return [];
|
|
203
|
+
return workspaceManifests(root,patterns).map(({file,pkg:member})=>
|
|
204
|
+
({name:member.name,dir:path.posix.dirname(file),scripts:member.scripts&&typeof member.scripts==='object'?member.scripts:{}}));
|
|
205
|
+
}
|
|
206
|
+
// The general recipe for a root script, or why it does not apply: a
|
|
207
|
+
// supported package manager and platform, and a script (with its pre and post
|
|
208
|
+
// scripts) that, read as text, starts only Node programs.
|
|
209
|
+
function generalCheck(root,kind,script){
|
|
210
|
+
if(!['darwin','linux'].includes(process.platform))throw Error('the general recipe records runs on macOS and Linux only');
|
|
211
|
+
const environmentIssue=testEnvironmentIssue();
|
|
212
|
+
if(environmentIssue)throw Error(environmentIssue);
|
|
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
|
+
}
|
|
220
|
+
return {config:{name:kind,script,kind,qualification:scriptQualification,...scriptCheckInputs(project.dirs)},manager:project.manager};
|
|
221
|
+
}
|
|
222
|
+
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 or node-script');
|
|
225
|
+
// Without --recipe, init tries the strict recipes first, then the general one.
|
|
226
|
+
const auto=recipe===null;
|
|
163
227
|
root=realObservedPath(root);
|
|
164
228
|
const packageFile=path.join(root,'package.json');
|
|
165
229
|
if(!exists(packageFile))throw Error('run init from a repository containing package.json');
|
|
@@ -176,36 +240,82 @@ export function discoverNodeProject(root) {
|
|
|
176
240
|
workspacePatterns.some(value=>typeof value!=='string'||path.isAbsolute(value)||
|
|
177
241
|
value.split('/').includes('..'))))
|
|
178
242
|
throw Error('workspace patterns need a supported project-relative package list');
|
|
179
|
-
const checks=[],ambiguous=[];
|
|
243
|
+
const checks=[],ambiguous=[],skipped=[];
|
|
180
244
|
for(const [kind,names] of Object.entries(scriptNames)) {
|
|
181
245
|
const selected=selectScript(kind,names,scripts),script=selected.script;
|
|
182
246
|
if(selected.ambiguous)ambiguous.push({kind,scripts:selected.ambiguous});
|
|
183
|
-
|
|
247
|
+
// With no typecheck script, a root tsconfig.json gets a proposed
|
|
248
|
+
// `tsc --noEmit` check; package.json stays unchanged. It is kept only
|
|
249
|
+
// when a contract makes it ready.
|
|
250
|
+
const proposed=!script&&!selected.ambiguous&&kind==='typecheck'&&!observationIssue&&
|
|
251
|
+
proposable(root,scripts);
|
|
252
|
+
if(!script&&!proposed)continue;
|
|
184
253
|
let classification=observationIssue?{level:'unsupported',reason:observationIssue}:
|
|
185
|
-
classify(kind,script,scripts[script],pkg,pm,root);
|
|
186
|
-
|
|
254
|
+
script?classify(kind,script,scripts[script],pkg,pm,root):{level:'recording',reason:'no typecheck script'};
|
|
255
|
+
let parts=null;
|
|
256
|
+
if(workspaces&&classification.qualification==='typescript-noemit-v1')classification={level:'recording',
|
|
187
257
|
reason:'workspace task and installed-input closure need explicit qualification'};
|
|
188
258
|
const check={name:kind,script,kind,inputs:sourceInputs(root,kind,pm.lockfiles||[])};
|
|
189
|
-
if(pm.name==='
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
259
|
+
if((recipe==='typescript-direct'||auto&&pm.name==='npm'&&!workspaces)&&kind==='typecheck'){
|
|
260
|
+
try{
|
|
261
|
+
if(pm.name!=='npm'||workspaces||observationIssue||pm.issue)throw Error('direct recipe requires a supported single-package npm checkout');
|
|
262
|
+
let version;
|
|
263
|
+
parts=tscChecks('typecheck',script??null,scripts,run=>{
|
|
264
|
+
version=directProject(root,run).version;
|
|
265
|
+
return {command:['@node','@typescript-compiler:.',...run.args],cwd:'.'};});
|
|
266
|
+
classification={level:'ready',qualification:directQualification,
|
|
267
|
+
reason:`explicit local TypeScript ${version} compilation; npm launcher/lifecycle scripts are not executed; package.json stays unchanged`};
|
|
268
|
+
}catch(e){classification={level:'recording',reason:`Direct recipe unavailable (${e.code||e.message}); original npm script retained, without bypassing required behavior`};}
|
|
269
|
+
}
|
|
270
|
+
if((recipe==='tests-direct'||auto)&&kind==='test'){
|
|
271
|
+
try{
|
|
272
|
+
if(!['npm','pnpm','yarn'].includes(pm.name)||workspaces||observationIssue||pm.issue)
|
|
273
|
+
throw Error('test recipe requires a supported single-package npm, pnpm or Yarn checkout');
|
|
274
|
+
const chosen=testRecipeScript(scripts);
|
|
275
|
+
if(chosen.ambiguous)throw Error(`several scripts run Vitest or Jest directly (${chosen.ambiguous.join(', ')})`);
|
|
276
|
+
if(!chosen.script)throw Error(chosen.issue||'no script runs Vitest or Jest directly');
|
|
277
|
+
const project=testProject(root,chosen.script);
|
|
278
|
+
Object.assign(check,{script:chosen.script,command:['@node',`@test-runner:${project.runner}`,...project.args],cwd:'.',
|
|
279
|
+
...testCheckInputs(project)});
|
|
280
|
+
classification={level:'ready',qualification:testsQualification,
|
|
281
|
+
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
|
+
}catch(e){classification={level:'recording',reason:`Test recipe unavailable (${e.code||e.message}); original npm script retained`};}
|
|
283
|
+
}
|
|
284
|
+
if(recipe!=='node-script'&&pm.name==='pnpm'&&kind==='typecheck'&&!workspaces&&!pm.issue&&(script===undefined||tscRuns(scripts[script]))){
|
|
285
|
+
try{
|
|
286
|
+
parts=tscChecks('typecheck',script??null,scripts,run=>{
|
|
287
|
+
const contract=pnpmTypecheckInputs(root,'.',run);
|
|
288
|
+
if(contract.limitations.length)throw Error(contract.limitations.join('; '));
|
|
289
|
+
return {command:['@node','@typescript-bin:.',...run.args],cwd:'.',
|
|
290
|
+
inputs:[...new Set(['package.json','pnpm-lock.yaml',
|
|
291
|
+
...(exists(path.join(root,'pnpm-workspace.yaml'))?['pnpm-workspace.yaml']:[]),
|
|
292
|
+
...contractInputs(contract)])]};});
|
|
293
|
+
classification={level:'ready',reason:'pnpm TypeScript inputs and installation; checked again before reuse',
|
|
200
294
|
qualification:'pnpm-tsc-v1'};}
|
|
201
295
|
catch(e){classification={level:'recording',reason:`pnpm TypeScript contract needs review: ${e.message}`};}
|
|
202
296
|
}
|
|
297
|
+
if((auto||recipe==='node-script')&&script&&generalKinds.includes(kind)&&classification.level!=='ready'&&!observationIssue){
|
|
298
|
+
try{
|
|
299
|
+
const general=generalCheck(root,kind,script);
|
|
300
|
+
checks.push({config:general.config,level:'ready',qualification:scriptQualification,
|
|
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`});
|
|
302
|
+
continue;
|
|
303
|
+
}catch(e){classification={...classification,reason:`General recipe unavailable (${e.message}); ${classification.reason}`};}
|
|
304
|
+
}
|
|
305
|
+
if(!script&&classification.level!=='ready')continue;
|
|
306
|
+
if(parts&&classification.level==='ready'){
|
|
307
|
+
for(const part of parts)checks.push({config:{...check,...part,qualification:classification.qualification},...classification});
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
203
310
|
if(classification.qualification)check.qualification=classification.qualification;
|
|
204
311
|
checks.push({config:check,...classification});
|
|
205
312
|
}
|
|
206
313
|
if(workspaces&&!observationIssue){
|
|
207
|
-
const seen=new Set();
|
|
208
|
-
|
|
314
|
+
const seen=new Set(),members=workspaceManifests(root,workspacePatterns);
|
|
315
|
+
// Past 50 packages, init proposes the root's checks only and says so.
|
|
316
|
+
if(members.length>perPackageLimit)skipped.push({kind:'workspace',packages:members.length,
|
|
317
|
+
reason:`${members.length} workspace packages, more than ${perPackageLimit}: per-package checks were not proposed; add the ones you need to the config`});
|
|
318
|
+
for(const {file,pkg:member} of members.length>perPackageLimit?[]:members){
|
|
209
319
|
const dir=path.posix.dirname(file),name=member.name;
|
|
210
320
|
if(typeof name!=='string'||!name||seen.has(name)){
|
|
211
321
|
ambiguous.push({kind:'workspace',scripts:[dir],reason:'missing or duplicate package name'});
|
|
@@ -217,6 +327,16 @@ export function discoverNodeProject(root) {
|
|
|
217
327
|
const selected=selectScript(kind,names,member.scripts||{});
|
|
218
328
|
if(selected.ambiguous){ambiguous.push({kind:`${name}:${kind}`,
|
|
219
329
|
scripts:selected.ambiguous});continue;}
|
|
330
|
+
// A pnpm package with a tsconfig.json and no typecheck script gets a
|
|
331
|
+
// proposed `tsc --noEmit` check when the pnpm contract holds.
|
|
332
|
+
if(!selected.script&&kind==='typecheck'&&pm.name==='pnpm'&&!pm.issue&&
|
|
333
|
+
proposable(path.join(root,dir),member.scripts)&&!typechecks(scripts)){
|
|
334
|
+
try{for(const part of tscChecks(`${name}:typecheck`,null,{},run=>pnpmCheck(root,name,dir,file,run)))
|
|
335
|
+
checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
|
|
336
|
+
reason:'proposed tsc --noEmit for the package\'s tsconfig.json; pnpm workspace TypeScript inputs; checked again before reuse'});}
|
|
337
|
+
catch{}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
220
340
|
if(!selected.script)continue;
|
|
221
341
|
const command=pm.name==='npm'?['@which:npm','run',selected.script,
|
|
222
342
|
'--workspace',name]:pm.name==='yarn'?
|
|
@@ -224,7 +344,7 @@ export function discoverNodeProject(root) {
|
|
|
224
344
|
['@which:pnpm','--filter',name,'run',selected.script];
|
|
225
345
|
const check={name:`${name}:${kind}`,kind,command,
|
|
226
346
|
inputs:['package.json',...(pm.lockfiles||[]),`${dir}/**`,
|
|
227
|
-
`!${dir}/node_modules/**`]};
|
|
347
|
+
`!${dir}/node_modules/**`,`!${dir}/**/node_modules/**`]};
|
|
228
348
|
let level=pm.issue&&pm.name!=='pnpm'?'unsupported':'recording';
|
|
229
349
|
let reason=pm.issue||'workspace script found; shared configuration and installed inputs need review';
|
|
230
350
|
if(!pm.issue&&pm.name==='yarn'&&pm.layout==='node-modules/classic'&&
|
|
@@ -237,27 +357,26 @@ export function discoverNodeProject(root) {
|
|
|
237
357
|
level='ready';reason='pinned Yarn workspace tsc and installed inputs; checked again before reuse';
|
|
238
358
|
}catch(e){reason=`workspace TypeScript contract needs review: ${e.message}`;}
|
|
239
359
|
}
|
|
240
|
-
if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&
|
|
241
|
-
|
|
242
|
-
(
|
|
243
|
-
|
|
244
|
-
|
|
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';
|
|
360
|
+
if(!pm.issue&&pm.name==='pnpm'&&kind==='typecheck'&&tscRuns((member.scripts||{})[selected.script])){
|
|
361
|
+
try{const parts=tscChecks(`${name}:typecheck`,selected.script,member.scripts,run=>pnpmCheck(root,name,dir,file,run));
|
|
362
|
+
for(const part of parts)checks.push({config:{kind,workspace:name,qualification:'pnpm-tsc-v1',...part},level:'ready',
|
|
363
|
+
reason:'pnpm workspace TypeScript inputs; checked again before reuse'});
|
|
364
|
+
continue;
|
|
254
365
|
}catch(e){reason=`pnpm TypeScript contract needs review: ${e.message}`;}
|
|
255
366
|
}
|
|
367
|
+
if(recipe==='tests-direct'&&kind==='test'&&!pm.issue){
|
|
368
|
+
try{const project=testProject(root,selected.script,dir);
|
|
369
|
+
Object.assign(check,{workspace:name,script:selected.script,cwd:dir,
|
|
370
|
+
command:['@node',`@test-runner:${project.runner}`,...project.args],...testCheckInputs(project),
|
|
371
|
+
qualification:testsQualification});
|
|
372
|
+
level='ready';reason=`explicit local ${project.runner} ${project.version} run of ${name}'s script ${JSON.stringify(selected.script)}; the whole checkout is its input; the package manager and lifecycle scripts are not executed`;
|
|
373
|
+
}catch(e){reason=`Test recipe unavailable (${e.code||e.message}); original script retained`;}
|
|
374
|
+
}
|
|
256
375
|
checks.push({config:check,level,reason});
|
|
257
376
|
}
|
|
258
377
|
}
|
|
259
378
|
}
|
|
260
379
|
return {schema:1,root,manager:pm,observationIssue,workspaces,node:process.version,
|
|
261
|
-
checks,ambiguous,
|
|
380
|
+
checks,ambiguous,skipped,
|
|
262
381
|
config:{schema:1,packageManager:pm.name||'unknown',checks:checks.map(row=>row.config)}};
|
|
263
382
|
}
|