@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
@@ -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'