@exodus/stasis 1.0.0-beta.5 → 1.0.0-beta.7

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.
@@ -861,9 +861,10 @@ export function phpClassDependencies(content) {
861
861
  if (impl) for (const nm of impl[1].split(',')) add(nm)
862
862
  }
863
863
 
864
- // catch (A | B $e): capture the type list up to `$var`. The lazy `[^)]*?` (no
865
- // second whitespace quantifier) keeps matching linear on adversarial input.
866
- for (const m of stripped.matchAll(/\bcatch\s*\(([^)]*?)\$\w+/giu)) {
864
+ // catch (A | B $e), or PHP 8's catch (A | B) with no variable: capture the type
865
+ // list up to `$var` or `)`. One quantifier with nothing after it keeps matching
866
+ // linear on adversarial input.
867
+ for (const m of stripped.matchAll(/\bcatch\s*\(([^)$]*)/giu)) {
867
868
  for (const nm of m[1].split('|')) add(nm)
868
869
  }
869
870
 
@@ -64,7 +64,7 @@ function getPackageForModule(absModulePath) {
64
64
  export function createMetroResolver({
65
65
  projectDir,
66
66
  platform,
67
- sourceExts = ['js', 'json', 'ts'],
67
+ sourceExts = ['js', 'jsx', 'json', 'ts', 'tsx'],
68
68
  mainFields = ['react-native', 'browser', 'main'],
69
69
  conditionNames = ['react-native'],
70
70
  conditionsByPlatform = { web: ['browser'] },
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url'
4
4
 
5
5
  import { isTypeDeclaration, stripTypeDeclaration, toPosix } from '@exodus/stasis-core/util'
6
6
  import {
7
+ inNodeModules,
7
8
  isDir,
8
9
  isFile,
9
10
  locatePackage,
@@ -174,12 +175,16 @@ function resolveSourceFile(base, opts) {
174
175
  // source siblings once the literal file (the bare probe above) is absent, so the on-disk `.js`
175
176
  // always wins over its `.ts` twin. Literal siblings only -- no platform suffixes (tsc has none);
176
177
  // extensionless bases keep going through the appended-extension loop below (sourceExts carries
177
- // ts, and tsx under --jsx). Placed before that loop so `x.js` -> `x.ts` beats a pathological
178
- // `x.js.<ext>`, matching tsc's candidate order.
178
+ // ts and tsx). Placed before that loop so `x.js` -> `x.ts` beats a pathological
179
+ // `x.js.<ext>`, matching tsc's candidate order. Never into node_modules: where tsc's pick, the
180
+ // first sibling on disk, lies there by its real path, nothing maps (inNodeModules), as the shared
181
+ // fallback refuses its own -- never a later sibling tsc wouldn't pick. The resolver drops
182
+ // `typescript` for an importer there.
179
183
  if (opts.typescript) {
180
- for (const cand of typescriptSiblings(base, { tsx: opts.sourceExts.includes('tsx') })) {
184
+ for (const cand of typescriptSiblings(base)) {
181
185
  const hit = probePath(cand, true)
182
- if (hit) return hit
186
+ if (hit === null) continue
187
+ return typeof hit === 'string' && inNodeModules(hit, opts.host) ? null : hit
183
188
  }
184
189
  }
185
190
  for (const ext of opts.sourceExts) {
@@ -220,7 +225,7 @@ function resolveFileOrDir(base, opts) {
220
225
  // resolveEntryThroughMap and resolveSourceFile); leave it off for the esbuild-parity
221
226
  // `--mainFields` path. `typescript` adds tsc's extension substitution (a missing `x.js` probes
222
227
  // its `x.ts` sibling; see resolveSourceFile) plus the shared miss fallback (see below), and
223
- // `typescriptPaths` (a loadTsconfigPaths matcher) its tsconfig alias mapping.
228
+ // `typescriptPaths` (a tsconfig paths matcher; see resolveTypescriptFallback) its tsconfig alias mapping.
224
229
  // `metroKeepEntryOnBrowserFalse` overrides the module-level toggle
225
230
  // (METRO_KEEP_ENTRY_ON_BROWSER_FALSE) per resolver -- primarily so tests can cover both branches.
226
231
  // `host` is the filesystem view (@exodus/stasis-core/host), the real disk by default.
@@ -229,7 +234,7 @@ export function createFieldResolver({
229
234
  mainFields = ['main'],
230
235
  platform = null,
231
236
  preferNative = false,
232
- sourceExts = ['js', 'json', 'ts'],
237
+ sourceExts = ['js', 'jsx', 'json', 'ts', 'tsx'],
233
238
  metro = false,
234
239
  typescript = false,
235
240
  typescriptPaths = null,
@@ -237,9 +242,12 @@ export function createFieldResolver({
237
242
  host = diskHost,
238
243
  } = {}) {
239
244
  const opts = { platform, preferNative, sourceExts, mainFields, metro, typescript, metroKeepEntryOnBrowserFalse, host }
245
+ // An importer in node_modules gets no --typescript mapping (inNodeModules), wherever its import lands.
246
+ const fromNodeModules = { ...opts, typescript: false }
240
247
  // `callConditions` (from scan) is the parent's format-driven condition set, so `exports`
241
248
  // delegation matches Node resolving from THAT file; falls back to configured `conditions`.
242
249
  const resolve = function resolve(parentFile, specifier, callConditions) {
250
+ const fileOpts = typescript && inNodeModules(parentFile, host) ? fromNodeModules : opts
243
251
  const conds = new Set(callConditions ?? conditions)
244
252
  const viaNode = (spec) => {
245
253
  try {
@@ -269,7 +277,7 @@ export function createFieldResolver({
269
277
  // Browser-map targets are relative to the PACKAGE ROOT, not the importing file; a
270
278
  // bare-module target re-enters resolution below.
271
279
  if (r.startsWith('.') || isAbsolute(r)) {
272
- return resolveFileOrDir(isAbsolute(r) ? r : resolvePath(imp.pkgDir, r), opts)
280
+ return resolveFileOrDir(isAbsolute(r) ? r : resolvePath(imp.pkgDir, r), fileOpts)
273
281
  }
274
282
  spec = r
275
283
  }
@@ -280,7 +288,7 @@ export function createFieldResolver({
280
288
 
281
289
  if (spec.startsWith('.') || isAbsolute(spec)) {
282
290
  const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
283
- return resolveFileOrDir(base, opts)
291
+ return resolveFileOrDir(base, fileOpts)
284
292
  }
285
293
 
286
294
  const loc = locatePackage(dirname(parentFile), spec, host)
@@ -289,12 +297,12 @@ export function createFieldResolver({
289
297
  // `exports` wins over mainFields; Node's algorithm resolves it (with conditions) correctly.
290
298
  if (pkg.exports != null) return viaNode(spec)
291
299
  // A bare package import resolves its directory via the same dir algorithm as any other.
292
- if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, opts)
300
+ if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
293
301
  const sub = `./${loc.subpath}`
294
302
  const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
295
303
  if (r === false) return { empty: true }
296
304
  const target = typeof r === 'string' ? r : sub
297
- return resolveFileOrDir(join(loc.pkgDir, target), opts)
305
+ return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
298
306
  }
299
307
  if (!typescript) return resolve
300
308
  // --typescript: when the whole field flow leaves the specifier unresolved, give tsc's mapping
@@ -308,7 +316,6 @@ export function createFieldResolver({
308
316
  if (resolved) return resolved
309
317
  const hit = resolveTypescriptFallback(parentFile, specifier, {
310
318
  conditions: new Set(callConditions ?? conditions),
311
- tsx: sourceExts.includes('tsx'),
312
319
  paths: typescriptPaths,
313
320
  host,
314
321
  })
@@ -8,7 +8,7 @@ import { diskHost } from '@exodus/stasis-core/host'
8
8
  // everywhere: when normal resolution misses and tsc would land on an on-disk TypeScript source,
9
9
  // complete the miss with that file. It never rewrites a resolution that succeeded, so an on-disk
10
10
  // `.js` always beats its `.ts` twin. The rules, in the shapes they apply to:
11
- // - extension substitution: `./x.js` -> `./x.ts` (`.jsx` -> `.tsx` under --jsx, `.mjs` -> `.mts`,
11
+ // - extension substitution: `./x.js` -> `./x.ts` (`.jsx` -> `.tsx`, `.mjs` -> `.mts`,
12
12
  // `.cjs` -> `.cts`) -- tsc never rewrites specifiers, so TS sources import each other by their
13
13
  // OUTPUT names, and the specifier names a file that only exists as its TS source;
14
14
  // - extension/index completion for an extensionless path (`./util` -> `./util.ts`,
@@ -16,13 +16,13 @@ import { diskHost } from '@exodus/stasis-core/host'
16
16
  // - the same substitution applied to manifest-declared targets: a package `main`, an `exports`
17
17
  // target, a `#name` `imports` target naming a compiled file whose only on-disk form is TS;
18
18
  // - tsconfig `compilerOptions.paths` aliases (see loadTsconfigPaths), consulted for bare
19
- // specifiers nothing else resolved.
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.
20
21
  // This module also homes the generic manifest helpers (readJson/locatePackage/nearestPackage)
21
22
  // both resolvers share -- it is the dependency-free lower layer, importing from neither.
22
23
 
23
24
  // tsc's extension substitution table: the JS output extensions mapped back to the TS source
24
- // siblings that may sit on disk in their place. `.tsx` entries apply only when the caller can
25
- // carry/parse .tsx (i.e. --jsx). JS_OUTPUT_EXTS is derived, so the two cannot drift.
25
+ // siblings that may sit on disk in their place. JS_OUTPUT_EXTS is derived, so the two cannot drift.
26
26
  const TS_SIBLING_EXTS = new Map([
27
27
  ['.js', ['.ts', '.tsx']],
28
28
  ['.jsx', ['.tsx']],
@@ -36,12 +36,12 @@ const JS_OUTPUT_EXTS = new Set(TS_SIBLING_EXTS.keys())
36
36
  // extname sees no extension in './.js', matching the extensionless gates downstream). Candidates
37
37
  // spelling a type declaration (./x.d.js -> ./x.d.ts) are returned too: every probe site refuses
38
38
  // declarations (types-only, erased at runtime), so the screen lives at probe time, once.
39
- export function typescriptSiblings(name, { tsx = false } = {}) {
39
+ export function typescriptSiblings(name) {
40
40
  const ext = extname(name)
41
41
  const exts = TS_SIBLING_EXTS.get(ext)
42
42
  if (!exts) return []
43
43
  const stem = name.slice(0, -ext.length)
44
- return exts.filter((e) => tsx || e !== '.tsx').map((e) => `${stem}${e}`)
44
+ return exts.map((e) => `${stem}${e}`)
45
45
  }
46
46
 
47
47
  // Extensions that never take the appended-`.ts` completion: JS outputs (substitution territory)
@@ -49,6 +49,15 @@ export function typescriptSiblings(name, { tsx = false } = {}) {
49
49
  // completed like an extensionless name, matching tsc's candidate list.
50
50
  const NO_COMPLETION_EXTS = new Set([...JS_OUTPUT_EXTS, '.ts', '.tsx', '.mts', '.cts', '.json'])
51
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
+
52
61
  export function isFile(p, host = diskHost) {
53
62
  return host.stat(p)?.isFile() ?? false
54
63
  }
@@ -113,8 +122,13 @@ export function nearestPackage(file, host = diskHost) {
113
122
  // (a `.d.ts` is types-only, erased at runtime -- tsc records it for types, never for emit).
114
123
  const probe = (p, host) => (!isTypeDeclaration(p) && isFile(p, host) ? p : null)
115
124
 
116
- // LOAD_INDEX with TS completions: dir/index.ts (+ index.tsx under tsx). Node/tsc already probed
117
- // the .js/.json indexes before the fallback ever runs.
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.
118
132
  function probeIndex(dir, exts, host) {
119
133
  for (const ext of exts) {
120
134
  const hit = probe(join(dir, `index${ext}`), host)
@@ -130,13 +144,14 @@ function probeIndex(dir, exts, host) {
130
144
  // LOAD_AS_DIRECTORY) then its index. `dirOnly` skips the file rules for specs Node treats as
131
145
  // directory-only ('.', '..', a trailing '/'), so './' never probes the pathological '.ts' dotfile.
132
146
  // `completion`/`dir` are off for exports/imports targets: Node requires those to name exact files,
133
- // so only substitution applies (matching tsc's node16 rules).
134
- function probeTypescriptTarget(base, { tsx = false, dirOnly = false, completion = true, dir = true, host = diskHost } = {}) {
135
- const exts = tsx ? ['.ts', '.tsx'] : ['.ts']
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)
136
151
  if (!dirOnly) {
137
152
  const literal = probe(base, host)
138
153
  if (literal) return literal
139
- for (const cand of typescriptSiblings(base, { tsx })) {
154
+ for (const cand of typescriptSiblings(base)) {
140
155
  const hit = probe(cand, host)
141
156
  if (hit) return hit
142
157
  }
@@ -154,7 +169,7 @@ function probeTypescriptTarget(base, { tsx = false, dirOnly = false, completion
154
169
  // LOAD_AS_FILE(main) with substitution/completion, then LOAD_INDEX(main); a broken main
155
170
  // falls through to the package index, like Node.
156
171
  const entry = resolvePath(base, main)
157
- const hit = probeTypescriptTarget(entry, { tsx, dir: false, host }) ?? probeIndex(entry, exts, host)
172
+ const hit = probeTypescriptTarget(entry, { js, dir: false, host }) ?? probeIndex(entry, exts, host)
158
173
  if (hit) return hit
159
174
  }
160
175
  return probeIndex(base, exts, host)
@@ -228,9 +243,9 @@ function manifestTargets(map, subpathKey, conditions) {
228
243
  // tsc's mapping of `key` ('.' or './sub') through the `exports` of the package in `pkgDir` (or of
229
244
  // a '#name' key through its `imports`): the targets `conditions` select, substitution only (Node
230
245
  // requires exports/imports targets to name exact files), the first on disk; or null.
231
- export function typescriptExportsTarget(pkgDir, exports, key, { conditions = new Set(), tsx = false, host = diskHost } = {}) {
246
+ export function typescriptExportsTarget(pkgDir, exports, key, { conditions = new Set(), host = diskHost } = {}) {
232
247
  for (const target of manifestTargets(exports, key, conditions)) {
233
- const hit = probeTypescriptTarget(resolvePath(pkgDir, target), { tsx, completion: false, dir: false, host })
248
+ const hit = probeTypescriptTarget(resolvePath(pkgDir, target), { completion: false, dir: false, host })
234
249
  if (hit) return hit
235
250
  }
236
251
  return null
@@ -299,24 +314,48 @@ function resolveExtendsTarget(fromFile, target, host) {
299
314
  throw new Error(`tsconfig extends target not found: '${target}' (from ${fromFile})`)
300
315
  }
301
316
 
302
- // Effective { paths, pathsDir, baseUrl } across the `extends` chain: bases apply in order, the
303
- // extending file overrides them; `paths` replaces wholesale (tsc never deep-merges it) and
304
- // remembers its declaring dir; `baseUrl` is resolved against its declaring file.
305
- function loadConfigChain(file, seen, host) {
306
- if (seen.has(file)) throw new Error(`tsconfig extends cycle at ${file}`)
307
- seen.add(file)
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}`)
308
325
  const raw = parseJsonc(host.readFile(file).toString('utf8'), file)
309
- const acc = {}
310
- for (const base of [].concat(raw?.extends ?? [])) {
311
- Object.assign(acc, loadConfigChain(resolveExtendsTarget(file, base, host), seen, host))
312
- }
313
- const co = raw?.compilerOptions ?? {}
314
- if (typeof co.baseUrl === 'string') acc.baseUrl = resolvePath(dirname(file), co.baseUrl)
315
- if (co.paths != null) {
316
- acc.paths = co.paths
317
- acc.pathsDir = dirname(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 }
318
357
  }
319
- return acc
358
+ return options
320
359
  }
321
360
 
322
361
  // Load a tsconfig's `compilerOptions.paths` into a matcher, following `extends`. Returns null when
@@ -324,11 +363,12 @@ function loadConfigChain(file, seen, host) {
324
363
  // substituted targets of the BEST-matching key -- exact match first, else the '*' pattern with the
325
364
  // longest matched prefix, exactly one key, its targets in order (tsc tries no other key when they
326
365
  // all miss) -- resolved against `baseUrl`, or against the declaring config's dir without one
327
- // (TS 4.1 paths-without-baseUrl). Only `extends`/`baseUrl`/`paths` are read; malformed shapes
328
- // (a non-array value, more than one '*' in a key or target) fail closed like tsc's config errors.
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.
329
369
  export function loadTsconfigPaths(file, host = diskHost) {
330
370
  if (file == null) return null
331
- const { paths, pathsDir, baseUrl } = loadConfigChain(file, new Set(), host)
371
+ const { paths, pathsBasePath, baseUrl } = loadTsconfigCompilerOptions(file, host)
332
372
  if (paths == null || typeof paths !== 'object' || Object.keys(paths).length === 0) return null
333
373
  const starCount = (s) => s.split('*').length - 1
334
374
  for (const [key, targets] of Object.entries(paths)) {
@@ -339,7 +379,7 @@ export function loadTsconfigPaths(file, host = diskHost) {
339
379
  if (starCount(t) > 1) throw new Error(`tsconfig paths pattern '${t}' has more than one '*' (${file})`)
340
380
  }
341
381
  }
342
- const base = baseUrl ?? pathsDir
382
+ const base = typeof baseUrl === 'string' ? baseUrl : pathsBasePath
343
383
  return {
344
384
  matchPaths(spec) {
345
385
  let targets = null
@@ -360,22 +400,84 @@ export function loadTsconfigPaths(file, host = diskHost) {
360
400
  targets = paths[best.key].map((t) => ({ target: t, wildcard }))
361
401
  }
362
402
  return targets.map(({ target, wildcard }) =>
363
- resolvePath(base, wildcard == null ? target : target.replace('*', wildcard)))
403
+ tsconfigPath(wildcard == null ? target : target.replace('*', wildcard), base, dirname(file)))
364
404
  },
365
405
  }
366
406
  }
367
407
 
368
- // The tsconfig `--typescript` reads: an explicit `--tsconfig` path must exist (fail closed on a
369
- // typo); with none given, the project root's tsconfig.json applies when present, like tsc's own
370
- // discovery from a directory.
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).
371
410
  export function discoverTsconfig(baseDir, explicit, host = diskHost) {
372
- if (explicit != null) {
373
- const p = resolvePath(baseDir, explicit)
374
- if (!isFile(p, host)) throw new Error(`tsconfig not found: ${explicit}`)
375
- return p
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
+ },
376
480
  }
377
- const p = join(baseDir, 'tsconfig.json')
378
- return isFile(p, host) ? p : null
379
481
  }
380
482
 
381
483
  // --- the fallback dispatcher ---
@@ -384,31 +486,43 @@ export function discoverTsconfig(baseDir, explicit, host = diskHost) {
384
486
  // the file rules are skipped for them, exactly as Node skips LOAD_AS_FILE.
385
487
  const DIR_ONLY_SPEC = /(?:^|\/)\.{1,2}$|\/$/u
386
488
 
387
- // tsconfig paths map first-party aliases; a dependency's own bare imports must never be hijacked
388
- // by the app's aliases (tsc doesn't resolve node_modules files' imports through the app config).
389
- const IN_NODE_MODULES = /(?:^|[\\/])node_modules[\\/]/u
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))
390
496
 
391
497
  // Resolve `spec` from `parentFile` the way tsc would complete a resolution BOTH Node and the
392
- // legacy-field resolver missed. Returns the absolute path of the on-disk source, or null.
393
- // `conditions` gates exports/imports maps (same set the failed resolution used); `tsx` widens the
394
- // substitutions to .tsx; `paths` is a loadTsconfigPaths matcher (or null). Dispatch by shape:
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:
395
502
  // '#name' -> the parent package's `imports` targets, substitution only;
396
503
  // relative/abs -> path substitution/completion (+ directory main/index);
397
- // bare -> tsconfig paths aliases first (tsc consults them before node_modules), then
504
+ // bare -> tsconfig paths aliases first (tsc consults them before node_modules; JS
505
+ // completion too, as Node never probed the target), then
398
506
  // the named package: its `exports` targets (substitution only) when it has
399
507
  // them, else its `main`/index (bare root) or subpath (substitution/completion).
400
- export function resolveTypescriptFallback(parentFile, spec, { conditions = new Set(), tsx = false, paths = null, host = diskHost } = {}) {
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 }) {
401
515
  if (spec.startsWith('#')) {
402
516
  const scope = nearestPackage(parentFile, host)
403
- return scope?.pkg.imports ? typescriptExportsTarget(scope.pkgDir, scope.pkg.imports, spec, { conditions, tsx, host }) : null
517
+ return scope?.pkg.imports ? typescriptExportsTarget(scope.pkgDir, scope.pkg.imports, spec, { conditions, host }) : null
404
518
  }
405
519
  if (spec.startsWith('./') || spec.startsWith('../') || spec === '.' || spec === '..' || isAbsolute(spec)) {
406
520
  const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
407
- return probeTypescriptTarget(base, { tsx, dirOnly: DIR_ONLY_SPEC.test(spec), host })
521
+ return probeTypescriptTarget(base, { dirOnly: DIR_ONLY_SPEC.test(spec), host })
408
522
  }
409
- if (paths && !IN_NODE_MODULES.test(parentFile)) {
410
- for (const target of paths.matchPaths(spec)) {
411
- const hit = probeTypescriptTarget(target, { tsx, dirOnly: target.endsWith('/'), host })
523
+ if (paths) {
524
+ for (const target of paths.matchPaths(spec, parentFile)) {
525
+ const hit = probeTypescriptTarget(target, { js: true, dirOnly: target.endsWith('/'), host })
412
526
  if (hit) return hit
413
527
  }
414
528
  }
@@ -417,11 +531,11 @@ export function resolveTypescriptFallback(parentFile, spec, { conditions = new S
417
531
  const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
418
532
  if (pkg.exports != null) {
419
533
  // `exports` fully governs a bare import (main is not a fallback); targets name exact files.
420
- return typescriptExportsTarget(loc.pkgDir, pkg.exports, loc.subpath === '' ? '.' : `./${loc.subpath}`, { conditions, tsx, host })
534
+ return typescriptExportsTarget(loc.pkgDir, pkg.exports, loc.subpath === '' ? '.' : `./${loc.subpath}`, { conditions, host })
421
535
  }
422
536
  if (loc.subpath === '') {
423
537
  // Bare package root: LOAD_AS_DIRECTORY only (never `node_modules/dep.ts`).
424
- return probeTypescriptTarget(loc.pkgDir, { tsx, dirOnly: true, host })
538
+ return probeTypescriptTarget(loc.pkgDir, { dirOnly: true, host })
425
539
  }
426
- return probeTypescriptTarget(join(loc.pkgDir, loc.subpath), { tsx, dirOnly: DIR_ONLY_SPEC.test(spec), host })
540
+ return probeTypescriptTarget(join(loc.pkgDir, loc.subpath), { dirOnly: DIR_ONLY_SPEC.test(spec), host })
427
541
  }
package/src/sbom.js CHANGED
@@ -10,7 +10,9 @@ const DEFAULT_TOOL = { name: '@exodus/stasis', version: pkg.version, vendor: 'Ex
10
10
  // Web Crypto (global) instead of node:crypto, keeping this module dependency-light.
11
11
  const newUuid = () => globalThis.crypto.randomUUID()
12
12
 
13
- // purl types we can mint; an ecosystem with no registered type (`soldeer`) gets no purl rather than a fabricated one.
13
+ // purl types we can mint; an ecosystem with no registered type (`soldeer`) gets no purl rather than a fabricated one,
14
+ // and so does a crate vendored from git or from a source not known (`cargo-git`, `cargo-unknown`): `pkg:cargo` names
15
+ // crates.io's crate of that name, which it may not be.
14
16
  const PURL_TYPES = new Set(['npm', 'composer', 'cargo', 'github'])
15
17
 
16
18
  // Infer an artifact's ecosystem from its loader formats: PHP bundles map to Composer, everything