@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6
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/README.md +9 -2
- package/bin/stasis.js +253 -22
- package/package.json +42 -10
- package/src/audit-corrections.js +72 -0
- package/src/audit.js +181 -112
- package/src/bundle-from-lockfile.js +3 -5
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +677 -402
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +22 -14
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +212 -127
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +9 -24
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +338 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +35 -19
- package/src/scan.js +211 -90
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +199 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +613 -0
- package/src/vfs-bundle.js +75 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { isTypeDeclaration } from '@exodus/stasis-core/util'
|
|
4
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
5
|
+
|
|
6
|
+
// tsc-style module resolution (`--typescript`), shared by BOTH JS resolvers -- scan.js's built-in
|
|
7
|
+
// Node resolver and resolve-fields.js's legacy-field resolver -- so the flag means one thing
|
|
8
|
+
// everywhere: when normal resolution misses and tsc would land on an on-disk TypeScript source,
|
|
9
|
+
// complete the miss with that file. It never rewrites a resolution that succeeded, so an on-disk
|
|
10
|
+
// `.js` always beats its `.ts` twin. The rules, in the shapes they apply to:
|
|
11
|
+
// - extension substitution: `./x.js` -> `./x.ts` (`.jsx` -> `.tsx`, `.mjs` -> `.mts`,
|
|
12
|
+
// `.cjs` -> `.cts`) -- tsc never rewrites specifiers, so TS sources import each other by their
|
|
13
|
+
// OUTPUT names, and the specifier names a file that only exists as its TS source;
|
|
14
|
+
// - extension/index completion for an extensionless path (`./util` -> `./util.ts`,
|
|
15
|
+
// `./dir` -> `./dir/index.ts`), incl. through a directory's package.json `main`;
|
|
16
|
+
// - the same substitution applied to manifest-declared targets: a package `main`, an `exports`
|
|
17
|
+
// target, a `#name` `imports` target naming a compiled file whose only on-disk form is TS;
|
|
18
|
+
// - tsconfig `compilerOptions.paths` aliases (see loadTsconfigPaths), consulted for bare
|
|
19
|
+
// specifiers nothing else resolved. Node never probed an alias target, so its completion also
|
|
20
|
+
// tries the JS extensions after the TS ones (`@/x` -> x.ts, then x.js), as tsc does.
|
|
21
|
+
// This module also homes the generic manifest helpers (readJson/locatePackage/nearestPackage)
|
|
22
|
+
// both resolvers share -- it is the dependency-free lower layer, importing from neither.
|
|
23
|
+
|
|
24
|
+
// tsc's extension substitution table: the JS output extensions mapped back to the TS source
|
|
25
|
+
// siblings that may sit on disk in their place. JS_OUTPUT_EXTS is derived, so the two cannot drift.
|
|
26
|
+
const TS_SIBLING_EXTS = new Map([
|
|
27
|
+
['.js', ['.ts', '.tsx']],
|
|
28
|
+
['.jsx', ['.tsx']],
|
|
29
|
+
['.mjs', ['.mts']],
|
|
30
|
+
['.cjs', ['.cts']],
|
|
31
|
+
])
|
|
32
|
+
const JS_OUTPUT_EXTS = new Set(TS_SIBLING_EXTS.keys())
|
|
33
|
+
|
|
34
|
+
// The TS source siblings of a path/specifier naming a JS output extension (./x.js -> ./x.ts).
|
|
35
|
+
// Returns [] for a non-substitutable name (extensionless, already-TS, .json, unknown, dotfile --
|
|
36
|
+
// extname sees no extension in './.js', matching the extensionless gates downstream). Candidates
|
|
37
|
+
// spelling a type declaration (./x.d.js -> ./x.d.ts) are returned too: every probe site refuses
|
|
38
|
+
// declarations (types-only, erased at runtime), so the screen lives at probe time, once.
|
|
39
|
+
export function typescriptSiblings(name) {
|
|
40
|
+
const ext = extname(name)
|
|
41
|
+
const exts = TS_SIBLING_EXTS.get(ext)
|
|
42
|
+
if (!exts) return []
|
|
43
|
+
const stem = name.slice(0, -ext.length)
|
|
44
|
+
return exts.map((e) => `${stem}${e}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Extensions that never take the appended-`.ts` completion: JS outputs (substitution territory)
|
|
48
|
+
// and the extensions a resolver could already land on directly. Anything else (`./x.service`) is
|
|
49
|
+
// completed like an extensionless name, matching tsc's candidate list.
|
|
50
|
+
const NO_COMPLETION_EXTS = new Set([...JS_OUTPUT_EXTS, '.ts', '.tsx', '.mts', '.cts', '.json'])
|
|
51
|
+
|
|
52
|
+
// `path`'s real path, or `path` itself where it has none (not on disk).
|
|
53
|
+
function realpathOr(path, host) {
|
|
54
|
+
try {
|
|
55
|
+
return host.realpath(path)
|
|
56
|
+
} catch {
|
|
57
|
+
return path
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isFile(p, host = diskHost) {
|
|
62
|
+
return host.stat(p)?.isFile() ?? false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isDir(p, host = diskHost) {
|
|
66
|
+
return host.stat(p)?.isDirectory() ?? false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readJson(file, host = diskHost) {
|
|
70
|
+
let text
|
|
71
|
+
try {
|
|
72
|
+
text = host.readFile(file).toString('utf8')
|
|
73
|
+
} catch {
|
|
74
|
+
return null // absent / unreadable -- no manifest here
|
|
75
|
+
}
|
|
76
|
+
// A manifest that EXISTS but is malformed must fail closed (Node throws rather than resolving past it).
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(text)
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
throw new Error(`Invalid package.json: ${file}`, { cause })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Nearest node_modules/<pkg> up from `fromDir` (handles @scope/name). Returns { pkgDir, subpath }
|
|
85
|
+
// (subpath '' = bare package import), or null if not installed up the tree.
|
|
86
|
+
export function locatePackage(fromDir, spec, host = diskHost) {
|
|
87
|
+
const parts = spec.split('/')
|
|
88
|
+
const pkgLen = spec.startsWith('@') ? 2 : 1
|
|
89
|
+
if (parts.length < pkgLen || parts.slice(0, pkgLen).some((p) => !p)) return null
|
|
90
|
+
const pkgName = parts.slice(0, pkgLen).join('/')
|
|
91
|
+
const subpath = parts.slice(pkgLen).join('/')
|
|
92
|
+
let dir = fromDir
|
|
93
|
+
while (true) {
|
|
94
|
+
// Skip a dir literally named node_modules (basename, not endsWith — `my-node_modules` must not match).
|
|
95
|
+
if (basename(dir) !== 'node_modules') {
|
|
96
|
+
const pkgDir = join(dir, 'node_modules', pkgName)
|
|
97
|
+
if (isDir(pkgDir, host)) return { pkgDir, subpath }
|
|
98
|
+
}
|
|
99
|
+
const parent = dirname(dir)
|
|
100
|
+
if (parent === dir) return null
|
|
101
|
+
dir = parent
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Nearest package.json at/above `file`'s dir — the package whose fields (browser/RN map, imports)
|
|
106
|
+
// govern the file's own imports.
|
|
107
|
+
export function nearestPackage(file, host = diskHost) {
|
|
108
|
+
let dir = dirname(file)
|
|
109
|
+
while (true) {
|
|
110
|
+
const pkgPath = join(dir, 'package.json')
|
|
111
|
+
if (isFile(pkgPath, host)) {
|
|
112
|
+
const pkg = readJson(pkgPath, host)
|
|
113
|
+
if (pkg) return { pkgDir: dir, pkg }
|
|
114
|
+
}
|
|
115
|
+
const parent = dirname(dir)
|
|
116
|
+
if (parent === dir) return null
|
|
117
|
+
dir = parent
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A probe candidate is a target only if it names a real file that is not a type declaration
|
|
122
|
+
// (a `.d.ts` is types-only, erased at runtime -- tsc records it for types, never for emit).
|
|
123
|
+
const probe = (p, host) => (!isTypeDeclaration(p) && isFile(p, host) ? p : null)
|
|
124
|
+
|
|
125
|
+
// The extensions tsc's completion appends, in its order (tryAddingExtensions): .ts, .tsx, then --
|
|
126
|
+
// with `js`, for a path Node never probed (a tsconfig paths target) -- .js, .jsx. A path Node
|
|
127
|
+
// already completed needs only the TS ones. .d.ts (tsc's third) is never a target (see probe).
|
|
128
|
+
const completionExts = (js) => (js ? ['.ts', '.tsx', '.js', '.jsx'] : ['.ts', '.tsx'])
|
|
129
|
+
|
|
130
|
+
// LOAD_INDEX with tsc's completions: dir/index.ts, dir/index.tsx (+ index.js/index.jsx with `js`).
|
|
131
|
+
// Without `js`, Node already probed the .js/.json indexes before the fallback ever runs.
|
|
132
|
+
function probeIndex(dir, exts, host) {
|
|
133
|
+
for (const ext of exts) {
|
|
134
|
+
const hit = probe(join(dir, `index${ext}`), host)
|
|
135
|
+
if (hit) return hit
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Probe an absolute `base` the way tsc completes a missed path: the literal file (manifest-declared
|
|
141
|
+
// targets are never probed by Node, so a paths/main target can hit literally), its TS siblings,
|
|
142
|
+
// the appended-extension completion, and -- when `dir` allows and `base` is a directory -- the
|
|
143
|
+
// directory's package.json `main` (substituted + completed like any path, mirroring Node's
|
|
144
|
+
// LOAD_AS_DIRECTORY) then its index. `dirOnly` skips the file rules for specs Node treats as
|
|
145
|
+
// directory-only ('.', '..', a trailing '/'), so './' never probes the pathological '.ts' dotfile.
|
|
146
|
+
// `completion`/`dir` are off for exports/imports targets: Node requires those to name exact files,
|
|
147
|
+
// so only substitution applies (matching tsc's node16 rules). `js` completes JS extensions too, for
|
|
148
|
+
// a base Node never probed (see completionExts).
|
|
149
|
+
function probeTypescriptTarget(base, { js = false, dirOnly = false, completion = true, dir = true, host = diskHost } = {}) {
|
|
150
|
+
const exts = completionExts(js)
|
|
151
|
+
if (!dirOnly) {
|
|
152
|
+
const literal = probe(base, host)
|
|
153
|
+
if (literal) return literal
|
|
154
|
+
for (const cand of typescriptSiblings(base)) {
|
|
155
|
+
const hit = probe(cand, host)
|
|
156
|
+
if (hit) return hit
|
|
157
|
+
}
|
|
158
|
+
if (completion && !NO_COMPLETION_EXTS.has(extname(base))) {
|
|
159
|
+
for (const ext of exts) {
|
|
160
|
+
const hit = probe(`${base}${ext}`, host)
|
|
161
|
+
if (hit) return hit
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (dir && isDir(base, host)) {
|
|
166
|
+
const pkg = readJson(join(base, 'package.json'), host)
|
|
167
|
+
const main = typeof pkg?.main === 'string' && pkg.main.length > 0 ? pkg.main : null
|
|
168
|
+
if (main) {
|
|
169
|
+
// LOAD_AS_FILE(main) with substitution/completion, then LOAD_INDEX(main); a broken main
|
|
170
|
+
// falls through to the package index, like Node.
|
|
171
|
+
const entry = resolvePath(base, main)
|
|
172
|
+
const hit = probeTypescriptTarget(entry, { js, dir: false, host }) ?? probeIndex(entry, exts, host)
|
|
173
|
+
if (hit) return hit
|
|
174
|
+
}
|
|
175
|
+
return probeIndex(base, exts, host)
|
|
176
|
+
}
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- package `exports`/`imports` map resolution (candidate generation) ---
|
|
181
|
+
|
|
182
|
+
// Expand a target value (string | array | conditions object | null) to the target strings the
|
|
183
|
+
// active `conditions` select, wildcard-substituted. Mirrors Node's PACKAGE_TARGET_RESOLVE closely
|
|
184
|
+
// enough for fallback candidates: a conditions object commits to its FIRST matching key ('default'
|
|
185
|
+
// always matches); an array contributes its entries in order (the probe decides).
|
|
186
|
+
function expandTarget(value, wildcard, conditions) {
|
|
187
|
+
if (typeof value === 'string') {
|
|
188
|
+
return [wildcard == null ? value : value.replaceAll('*', wildcard)]
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
return value.flatMap((v) => expandTarget(v, wildcard, conditions))
|
|
192
|
+
}
|
|
193
|
+
if (value !== null && typeof value === 'object') {
|
|
194
|
+
for (const [key, v] of Object.entries(value)) {
|
|
195
|
+
if (key === 'default' || conditions.has(key)) return expandTarget(v, wildcard, conditions)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [] // null (an explicit block) or no matching condition
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Resolve `key` through an exports/imports-shaped map: exact key first (a key containing '*' is
|
|
202
|
+
// never exact), else the best '*' pattern -- longest prefix, then longest key, exactly Node's
|
|
203
|
+
// PATTERN_KEY_COMPARE order -- with the matched wildcard substituted into the targets.
|
|
204
|
+
function resolveMapKey(map, key, conditions) {
|
|
205
|
+
if (Object.hasOwn(map, key) && !key.includes('*')) return expandTarget(map[key], null, conditions)
|
|
206
|
+
let best = null
|
|
207
|
+
for (const pattern of Object.keys(map)) {
|
|
208
|
+
const star = pattern.indexOf('*')
|
|
209
|
+
if (star === -1 || pattern.indexOf('*', star + 1) !== -1) continue
|
|
210
|
+
const prefix = pattern.slice(0, star)
|
|
211
|
+
const suffix = pattern.slice(star + 1)
|
|
212
|
+
if (key.length < prefix.length + suffix.length || !key.startsWith(prefix) || !key.endsWith(suffix)) continue
|
|
213
|
+
if (best === null || prefix.length > best.prefix.length
|
|
214
|
+
|| (prefix.length === best.prefix.length && pattern.length > best.pattern.length)) {
|
|
215
|
+
best = { pattern, prefix, suffix }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!best) return []
|
|
219
|
+
const wildcard = key.slice(best.prefix.length, key.length - best.suffix.length)
|
|
220
|
+
return expandTarget(map[best.pattern], wildcard, conditions)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A './' map target is only followed when it stays inside the package, like Node's
|
|
224
|
+
// PACKAGE_TARGET_RESOLVE invalid-target rules ('..' hops and node_modules re-entry are refused).
|
|
225
|
+
function validRelativeTarget(target) {
|
|
226
|
+
return target.startsWith('./')
|
|
227
|
+
&& !target.split('/').some((seg) => seg === '..' || seg === 'node_modules')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Candidate targets for `subpathKey` ('.', './sub', or '#name') through a package's exports or
|
|
231
|
+
// imports map. Exports sugar (a bare string/array/conditions object) is the '.' target.
|
|
232
|
+
function manifestTargets(map, subpathKey, conditions) {
|
|
233
|
+
if (map == null) return []
|
|
234
|
+
let byKey = map
|
|
235
|
+
if (typeof map !== 'object' || Array.isArray(map)
|
|
236
|
+
|| Object.keys(map).every((k) => !k.startsWith('.') && !k.startsWith('#'))) {
|
|
237
|
+
if (subpathKey !== '.') return []
|
|
238
|
+
byKey = { '.': map }
|
|
239
|
+
}
|
|
240
|
+
return resolveMapKey(byKey, subpathKey, conditions).filter((t) => validRelativeTarget(t))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// tsc's mapping of `key` ('.' or './sub') through the `exports` of the package in `pkgDir` (or of
|
|
244
|
+
// a '#name' key through its `imports`): the targets `conditions` select, substitution only (Node
|
|
245
|
+
// requires exports/imports targets to name exact files), the first on disk; or null.
|
|
246
|
+
export function typescriptExportsTarget(pkgDir, exports, key, { conditions = new Set(), host = diskHost } = {}) {
|
|
247
|
+
for (const target of manifestTargets(exports, key, conditions)) {
|
|
248
|
+
const hit = probeTypescriptTarget(resolvePath(pkgDir, target), { completion: false, dir: false, host })
|
|
249
|
+
if (hit) return hit
|
|
250
|
+
}
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- tsconfig `compilerOptions.paths` ---
|
|
255
|
+
|
|
256
|
+
// tsconfig.json is JSONC: strip // and /* */ comments and trailing commas (never inside string
|
|
257
|
+
// literals), then JSON.parse. A config that exists but does not parse must fail closed.
|
|
258
|
+
function parseJsonc(text, file) {
|
|
259
|
+
if (text.charCodeAt(0) === 0xFE_FF) text = text.slice(1) // BOM
|
|
260
|
+
let out = ''
|
|
261
|
+
let pendingComma = -1 // index in `out` of a comma that may turn out to be trailing
|
|
262
|
+
for (let i = 0; i < text.length; i++) {
|
|
263
|
+
const c = text[i]
|
|
264
|
+
if (c === '"') {
|
|
265
|
+
let j = i + 1
|
|
266
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1
|
|
267
|
+
out += text.slice(i, j + 1)
|
|
268
|
+
i = j
|
|
269
|
+
pendingComma = -1
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
273
|
+
const nl = text.indexOf('\n', i)
|
|
274
|
+
i = (nl === -1 ? text.length : nl) - 1
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
278
|
+
const end = text.indexOf('*/', i + 2)
|
|
279
|
+
i = end === -1 ? text.length : end + 1
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
if (c === ',') {
|
|
283
|
+
pendingComma = out.length
|
|
284
|
+
} else if (c === '}' || c === ']') {
|
|
285
|
+
if (pendingComma !== -1) out = `${out.slice(0, pendingComma)}${out.slice(pendingComma + 1)}`
|
|
286
|
+
pendingComma = -1
|
|
287
|
+
} else if (pendingComma !== -1 && !/\s/u.test(c)) {
|
|
288
|
+
pendingComma = -1
|
|
289
|
+
}
|
|
290
|
+
out += c
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(out)
|
|
294
|
+
} catch (cause) {
|
|
295
|
+
throw new Error(`Invalid tsconfig: ${file}`, { cause })
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Resolve an `extends` target like tsc: relative/absolute against the extending file (with the
|
|
300
|
+
// implied .json), bare through node_modules (the spelled path, its .json twin, or the package's
|
|
301
|
+
// tsconfig.json). A named base that cannot be found fails closed -- its options are load-bearing.
|
|
302
|
+
function resolveExtendsTarget(fromFile, target, host) {
|
|
303
|
+
if (target.startsWith('./') || target.startsWith('../') || isAbsolute(target)) {
|
|
304
|
+
const p = resolvePath(dirname(fromFile), target)
|
|
305
|
+
if (isFile(p, host)) return p
|
|
306
|
+
if (isFile(`${p}.json`, host)) return `${p}.json`
|
|
307
|
+
} else {
|
|
308
|
+
for (const cand of [target, `${target}.json`, `${target}/tsconfig.json`]) {
|
|
309
|
+
try {
|
|
310
|
+
return host.resolve(fromFile, cand)
|
|
311
|
+
} catch { /* not this spelling -- try the next */ }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
throw new Error(`tsconfig extends target not found: '${target}' (from ${fromFile})`)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The configs a tsconfig's `extends` chain reads, as { file, raw }: each base (in the order
|
|
318
|
+
// `extends` names them) before the config extending it, and `file` last. A base two configs share
|
|
319
|
+
// is listed under each, so folding the list in order, later over earlier, is tsc's nested merge,
|
|
320
|
+
// where the second extender's resolved options carry the shared base's again. Like tsc's, the
|
|
321
|
+
// cycle check is per branch: `extending` holds the configs extending `file`, and only a config that
|
|
322
|
+
// extends itself, directly or not, throws.
|
|
323
|
+
function* tsconfigChain(file, host, extending = new Set()) {
|
|
324
|
+
if (extending.has(file)) throw new Error(`tsconfig extends cycle at ${file}`)
|
|
325
|
+
const raw = parseJsonc(host.readFile(file).toString('utf8'), file)
|
|
326
|
+
extending.add(file)
|
|
327
|
+
for (const base of [].concat(raw?.extends ?? [])) yield* tsconfigChain(resolveExtendsTarget(file, base, host), host, extending)
|
|
328
|
+
extending.delete(file)
|
|
329
|
+
yield { file, raw }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// tsc's path-valued compilerOptions (a list's element by element), each made absolute against the
|
|
333
|
+
// dir of the config declaring it, or, when it starts with `${configDir}`, against the loaded one's.
|
|
334
|
+
const TSCONFIG_PATH_OPTIONS = new Set(['baseUrl', 'declarationDir', 'generateCpuProfile', 'generateTrace', 'outDir', 'outFile', 'rootDir', 'tsBuildInfoFile'])
|
|
335
|
+
const TSCONFIG_PATH_LIST_OPTIONS = new Set(['rootDirs', 'typeRoots'])
|
|
336
|
+
const CONFIG_DIR = '${configDir}'
|
|
337
|
+
const tsconfigPath = (value, dir, configDir) => {
|
|
338
|
+
if (typeof value !== 'string') return value
|
|
339
|
+
return value.startsWith(CONFIG_DIR) ? resolvePath(configDir, value.replace(CONFIG_DIR, './')) : resolvePath(dir, value)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The `compilerOptions` a tsconfig resolves to across its `extends` chain, merged one level deep
|
|
343
|
+
// as tsc merges them: each base in order, the extending file's own over them (`null` unsetting an
|
|
344
|
+
// option), each path-valued option absolute, and `paths` as declared, with tsc's `pathsBasePath`,
|
|
345
|
+
// the dir of the config declaring it.
|
|
346
|
+
export function loadTsconfigCompilerOptions(file, host = diskHost) {
|
|
347
|
+
let options = {}
|
|
348
|
+
for (const { file: at, raw } of tsconfigChain(file, host)) {
|
|
349
|
+
const own = { ...raw?.compilerOptions }
|
|
350
|
+
for (const [name, value] of Object.entries(own)) {
|
|
351
|
+
if (TSCONFIG_PATH_OPTIONS.has(name)) own[name] = tsconfigPath(value, dirname(at), dirname(file))
|
|
352
|
+
else if (TSCONFIG_PATH_LIST_OPTIONS.has(name) && Array.isArray(value)) own[name] = value.map((p) => tsconfigPath(p, dirname(at), dirname(file)))
|
|
353
|
+
}
|
|
354
|
+
delete own.pathsBasePath // tsc's own, never a config's
|
|
355
|
+
if (own.paths != null) own.pathsBasePath = dirname(at)
|
|
356
|
+
options = { ...options, ...own }
|
|
357
|
+
}
|
|
358
|
+
return options
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Load a tsconfig's `compilerOptions.paths` into a matcher, following `extends`. Returns null when
|
|
362
|
+
// `file` is null or the effective config declares no paths. matchPaths(spec) returns the absolute
|
|
363
|
+
// substituted targets of the BEST-matching key -- exact match first, else the '*' pattern with the
|
|
364
|
+
// longest matched prefix, exactly one key, its targets in order (tsc tries no other key when they
|
|
365
|
+
// all miss) -- resolved against `baseUrl`, or against the declaring config's dir without one
|
|
366
|
+
// (TS 4.1 paths-without-baseUrl), or, starting with `${configDir}`, against `file`'s. Only
|
|
367
|
+
// `baseUrl`/`paths` are used; malformed shapes (a non-array value, more than one '*' in a key or
|
|
368
|
+
// target) fail closed like tsc's config errors.
|
|
369
|
+
export function loadTsconfigPaths(file, host = diskHost) {
|
|
370
|
+
if (file == null) return null
|
|
371
|
+
const { paths, pathsBasePath, baseUrl } = loadTsconfigCompilerOptions(file, host)
|
|
372
|
+
if (paths == null || typeof paths !== 'object' || Object.keys(paths).length === 0) return null
|
|
373
|
+
const starCount = (s) => s.split('*').length - 1
|
|
374
|
+
for (const [key, targets] of Object.entries(paths)) {
|
|
375
|
+
if (!Array.isArray(targets) || targets.some((t) => typeof t !== 'string')) {
|
|
376
|
+
throw new Error(`tsconfig paths['${key}'] must be an array of strings (${file})`)
|
|
377
|
+
}
|
|
378
|
+
for (const t of [key, ...targets]) {
|
|
379
|
+
if (starCount(t) > 1) throw new Error(`tsconfig paths pattern '${t}' has more than one '*' (${file})`)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const base = typeof baseUrl === 'string' ? baseUrl : pathsBasePath
|
|
383
|
+
return {
|
|
384
|
+
matchPaths(spec) {
|
|
385
|
+
let targets = null
|
|
386
|
+
if (Object.hasOwn(paths, spec) && !spec.includes('*')) {
|
|
387
|
+
targets = paths[spec].map((t) => ({ target: t, wildcard: null }))
|
|
388
|
+
} else {
|
|
389
|
+
let best = null
|
|
390
|
+
for (const key of Object.keys(paths)) {
|
|
391
|
+
const star = key.indexOf('*')
|
|
392
|
+
if (star === -1) continue
|
|
393
|
+
const prefix = key.slice(0, star)
|
|
394
|
+
const suffix = key.slice(star + 1)
|
|
395
|
+
if (spec.length < prefix.length + suffix.length || !spec.startsWith(prefix) || !spec.endsWith(suffix)) continue
|
|
396
|
+
if (best === null || prefix.length > best.prefix.length) best = { key, prefix, suffix }
|
|
397
|
+
}
|
|
398
|
+
if (!best) return []
|
|
399
|
+
const wildcard = spec.slice(best.prefix.length, spec.length - best.suffix.length)
|
|
400
|
+
targets = paths[best.key].map((t) => ({ target: t, wildcard }))
|
|
401
|
+
}
|
|
402
|
+
return targets.map(({ target, wildcard }) =>
|
|
403
|
+
tsconfigPath(wildcard == null ? target : target.replace('*', wildcard), base, dirname(file)))
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The tsconfig an explicit `--tsconfig` names, resolved against `baseDir`; it must exist (fail
|
|
409
|
+
// closed on a typo). Without one, each package's own applies (packageTsconfigPaths).
|
|
410
|
+
export function discoverTsconfig(baseDir, explicit, host = diskHost) {
|
|
411
|
+
const p = resolvePath(baseDir, explicit)
|
|
412
|
+
if (!isFile(p, host)) throw new Error(`tsconfig not found: ${explicit}`)
|
|
413
|
+
return p
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Whether `dir` holds a package: a package.json naming one. One with no name is a `type` marker,
|
|
417
|
+
// its files the package's above (as vfs-bundle's holdsPackage reads it).
|
|
418
|
+
const holdsNamedPackage = (dir, host) => isFile(join(dir, 'package.json'), host) && readJson(join(dir, 'package.json'), host)?.name !== undefined
|
|
419
|
+
|
|
420
|
+
// Whether `dir` declares a monorepo's workspaces: a pnpm-workspace.yaml, or a package.json
|
|
421
|
+
// `workspaces` (npm's and yarn's).
|
|
422
|
+
const declaresWorkspaces = (dir, host) => isFile(join(dir, 'pnpm-workspace.yaml'), host) || Boolean(readJson(join(dir, 'package.json'), host)?.workspaces)
|
|
423
|
+
|
|
424
|
+
// The `paths` --typescript honours without --tsconfig: those of the tsconfig.json beside the
|
|
425
|
+
// package.json of the package the importing file is in, as a matcher whose matchPaths(spec,
|
|
426
|
+
// parentFile) is loadTsconfigPaths's for that config. The project is read up from `cwd` as the
|
|
427
|
+
// State reads it: the directories holding a package.json, up to one holding .git or
|
|
428
|
+
// pnpm-workspace.yaml, or up to `projectCwd` (the State's PROJECT_CWD, as yarn sets it). In a monorepo -- where one of those declares workspaces -- a file's package is
|
|
429
|
+
// the nearest directory at or above it, up to that workspace root, holding a named package.json
|
|
430
|
+
// (the root itself where none below it does); outside one, every file takes the one config beside
|
|
431
|
+
// the nearest named package.json at or above `cwd` (or the top-most package.json, where none is
|
|
432
|
+
// named). Never in node_modules: no walk enters a node_modules directory, and resolveTypescriptFallback
|
|
433
|
+
// matches no alias for a file under one. Each config loads once, when first matched; with `lenient`,
|
|
434
|
+
// one that doesn't load matches nothing instead of throwing.
|
|
435
|
+
export function packageTsconfigPaths(cwd, { host = diskHost, lenient = false, projectCwd } = {}) {
|
|
436
|
+
const stop = projectCwd ? realpathOr(resolvePath(projectCwd), host) : null
|
|
437
|
+
const dirs = [] // cwd and above, while the State's walk would go on; real paths, as importers are read
|
|
438
|
+
for (let dir = realpathOr(resolvePath(cwd), host); basename(dir) !== 'node_modules'; dir = dirname(dir)) {
|
|
439
|
+
dirs.push(dir)
|
|
440
|
+
if (dir === stop) break
|
|
441
|
+
if (host.stat(join(dir, '.git')) !== null || isFile(join(dir, 'pnpm-workspace.yaml'), host)) break
|
|
442
|
+
if (dirname(dir) === dir) break
|
|
443
|
+
}
|
|
444
|
+
const workspaceRoot = dirs.find((dir) => declaresWorkspaces(dir, host)) ?? null
|
|
445
|
+
const projectPackage = workspaceRoot === null
|
|
446
|
+
? dirs.find((dir) => holdsNamedPackage(dir, host)) ?? dirs.findLast((dir) => isFile(join(dir, 'package.json'), host)) ?? null
|
|
447
|
+
: null
|
|
448
|
+
// The package directory `parentFile` is in, or null outside the workspace root. By its real path,
|
|
449
|
+
// as inNodeModules reads it: a linked workspace package reached through node_modules takes its
|
|
450
|
+
// own config, read where it lies, so a relative `extends` resolves as tsc resolves it.
|
|
451
|
+
const packageOf = (parentFile) => {
|
|
452
|
+
if (workspaceRoot === null) return projectPackage
|
|
453
|
+
for (let dir = dirname(realpathOr(parentFile, host)); basename(dir) !== 'node_modules'; dir = dirname(dir)) {
|
|
454
|
+
if (dir === workspaceRoot || holdsNamedPackage(dir, host)) return dir
|
|
455
|
+
if (dirname(dir) === dir) return null
|
|
456
|
+
}
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
const matchers = new Map() // package dir -> loadTsconfigPaths matcher, or null
|
|
460
|
+
const matcherOf = (dir) => {
|
|
461
|
+
if (!matchers.has(dir)) {
|
|
462
|
+
const file = join(dir, 'tsconfig.json')
|
|
463
|
+
let matcher = null
|
|
464
|
+
if (isFile(file, host)) {
|
|
465
|
+
try {
|
|
466
|
+
matcher = loadTsconfigPaths(file, host)
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (!lenient) throw error
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
matchers.set(dir, matcher)
|
|
472
|
+
}
|
|
473
|
+
return matchers.get(dir)
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
matchPaths(spec, parentFile) {
|
|
477
|
+
const dir = packageOf(parentFile)
|
|
478
|
+
return dir === null ? [] : (matcherOf(dir)?.matchPaths(spec) ?? [])
|
|
479
|
+
},
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// --- the fallback dispatcher ---
|
|
484
|
+
|
|
485
|
+
// Specs Node resolves as a directory only ('.', '..', a './..'-style tail, a trailing '/'):
|
|
486
|
+
// the file rules are skipped for them, exactly as Node skips LOAD_AS_FILE.
|
|
487
|
+
const DIR_ONLY_SPEC = /(?:^|\/)\.{1,2}$|\/$/u
|
|
488
|
+
|
|
489
|
+
// A node_modules path segment, the last one included (the directory itself).
|
|
490
|
+
const IN_NODE_MODULES = /(?:^|[\\/])node_modules(?:[\\/]|$)/u
|
|
491
|
+
|
|
492
|
+
// Whether `file` lies in node_modules, by its real path where it has one (by its own, where it
|
|
493
|
+
// doesn't): --typescript maps nothing from or into an installed package. A monorepo's workspace
|
|
494
|
+
// packages, linked in through node_modules, really lie outside it, so they keep the mapping.
|
|
495
|
+
export const inNodeModules = (file, host = diskHost) => IN_NODE_MODULES.test(realpathOr(file, host))
|
|
496
|
+
|
|
497
|
+
// Resolve `spec` from `parentFile` the way tsc would complete a resolution BOTH Node and the
|
|
498
|
+
// legacy-field resolver missed. Returns the absolute path of the on-disk source, or null -- always
|
|
499
|
+
// null from or into node_modules (inNodeModules). `conditions` gates exports/imports maps (same set
|
|
500
|
+
// the failed resolution used); `paths` is a tsconfig paths matcher (loadTsconfigPaths, or
|
|
501
|
+
// packageTsconfigPaths), or null. Dispatch by shape:
|
|
502
|
+
// '#name' -> the parent package's `imports` targets, substitution only;
|
|
503
|
+
// relative/abs -> path substitution/completion (+ directory main/index);
|
|
504
|
+
// bare -> tsconfig paths aliases first (tsc consults them before node_modules; JS
|
|
505
|
+
// completion too, as Node never probed the target), then
|
|
506
|
+
// the named package: its `exports` targets (substitution only) when it has
|
|
507
|
+
// them, else its `main`/index (bare root) or subpath (substitution/completion).
|
|
508
|
+
export function resolveTypescriptFallback(parentFile, spec, { conditions = new Set(), paths = null, host = diskHost } = {}) {
|
|
509
|
+
if (inNodeModules(parentFile, host)) return null
|
|
510
|
+
const hit = typescriptTarget(parentFile, spec, { conditions, paths, host })
|
|
511
|
+
return hit == null || inNodeModules(hit, host) ? null : hit
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function typescriptTarget(parentFile, spec, { conditions, paths, host }) {
|
|
515
|
+
if (spec.startsWith('#')) {
|
|
516
|
+
const scope = nearestPackage(parentFile, host)
|
|
517
|
+
return scope?.pkg.imports ? typescriptExportsTarget(scope.pkgDir, scope.pkg.imports, spec, { conditions, host }) : null
|
|
518
|
+
}
|
|
519
|
+
if (spec.startsWith('./') || spec.startsWith('../') || spec === '.' || spec === '..' || isAbsolute(spec)) {
|
|
520
|
+
const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
|
|
521
|
+
return probeTypescriptTarget(base, { dirOnly: DIR_ONLY_SPEC.test(spec), host })
|
|
522
|
+
}
|
|
523
|
+
if (paths) {
|
|
524
|
+
for (const target of paths.matchPaths(spec, parentFile)) {
|
|
525
|
+
const hit = probeTypescriptTarget(target, { js: true, dirOnly: target.endsWith('/'), host })
|
|
526
|
+
if (hit) return hit
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const loc = locatePackage(dirname(parentFile), spec, host)
|
|
530
|
+
if (!loc) return null
|
|
531
|
+
const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
|
|
532
|
+
if (pkg.exports != null) {
|
|
533
|
+
// `exports` fully governs a bare import (main is not a fallback); targets name exact files.
|
|
534
|
+
return typescriptExportsTarget(loc.pkgDir, pkg.exports, loc.subpath === '' ? '.' : `./${loc.subpath}`, { conditions, host })
|
|
535
|
+
}
|
|
536
|
+
if (loc.subpath === '') {
|
|
537
|
+
// Bare package root: LOAD_AS_DIRECTORY only (never `node_modules/dep.ts`).
|
|
538
|
+
return probeTypescriptTarget(loc.pkgDir, { dirOnly: true, host })
|
|
539
|
+
}
|
|
540
|
+
return probeTypescriptTarget(join(loc.pkgDir, loc.subpath), { dirOnly: DIR_ONLY_SPEC.test(spec), host })
|
|
541
|
+
}
|
package/src/rollup.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Public-export adapter: backs `@exodus/stasis/rollup`. The plugin lives in
|
|
2
|
+
// @exodus/stasis-plugins (which depends on @exodus/stasis-core for State /
|
|
3
|
+
// resolvePluginState). Internal code uses `@exodus/stasis-plugins/rollup` directly;
|
|
4
|
+
// this file exists only to back the package's `exports` map.
|
|
5
|
+
export * from '@exodus/stasis-plugins/rollup'
|