@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.
package/src/scan.js CHANGED
@@ -1,36 +1,34 @@
1
- import { extname, resolve as resolvePath, relative } from 'node:path'
1
+ import { dirname, extname, resolve as resolvePath, relative } from 'node:path'
2
2
  import { fileURLToPath, pathToFileURL } from 'node:url'
3
3
  import { createRequire, isBuiltin } from 'node:module'
4
4
  import assert from 'node:assert/strict'
5
5
  import { packageType } from '@exodus/stasis-core/bundle-util'
6
- import { classifyExtension, classifyFormat, relativeEscapes } from '@exodus/stasis-core/util'
7
- import { resolveTypescriptFallback } from './resolve-typescript.js'
6
+ import { classifyExtension, classifyFormat, isTypeDeclaration, relativeEscapes } from '@exodus/stasis-core/util'
7
+ import { inNodeModules, resolveTypescriptFallback, typescriptSiblings } from './resolve-typescript.js'
8
8
  import { diskHost } from '@exodus/stasis-core/host'
9
9
 
10
10
  // Static require/import graph walker: parses source, never loads or executes user code.
11
11
  // Dynamic specifiers (`require(name)`, `import('./'+x)`) are recorded as unresolved.
12
12
 
13
- const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts'])
13
+ // .jsx/.tsx need no flag: oxc parses them as JSX/TSX purely from the filename, so a reached one
14
+ // -- e.g. a package whose React Native entry is src/index.tsx -- is parsed and carried like any .ts.
15
+ const SCRIPT_EXTS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.jsx', '.tsx'])
14
16
  const RESOLVABLE_EXTS = new Set([...SCRIPT_EXTS, '.json'])
15
- // Under `jsx`, these get parsed with JSX enabled. Excludes the .ts family: TypeScript's `<T>`
17
+ // The extensions whose name can't tell whether they hold JSX, parsed with JSX enabled only under
18
+ // `jsx` (React Native's JSX-in-.js convention). Excludes the .ts family: TypeScript's `<T>`
16
19
  // generics collide with JSX, so tsc/oxc reserve JSX for .tsx — JSX-in-TS belongs in a .tsx file.
17
20
  const JSX_EXTS = new Set(['.js', '.cjs', '.mjs'])
18
- // The inherently-JSX extensions (oxc parses them as JSX/TSX purely from the filename, no flag).
19
- // Scanned + carried only under `--jsx`: off by default the scanner treats them as opaque leaves,
20
- // so a bundle whose toolchain can't build JSX never silently ships .jsx/.tsx source. Under `--jsx`
21
- // they join SCRIPT_EXTS/RESOLVABLE_EXTS (see the constructor) so a reached .jsx/.tsx dependency
22
- // — e.g. a package whose React Native entry is src/index.tsx — is parsed and carried like any .ts.
23
- const JSX_FILE_EXTS = new Set(['.jsx', '.tsx'])
24
21
 
25
22
  // Flow type syntax lives in the JS (non-TS) family; oxc parses TS natively, and running
26
23
  // flow-remove-types over a .ts/.tsx file would corrupt TS-only constructs, so --flow only strips
27
24
  // these. .jsx is included (it's JS + JSX, and can carry Flow types like .js); .tsx is not (it's
28
25
  // TypeScript). flow-remove-types preserves JSX, so a stripped .jsx re-parses under lang:jsx.
29
26
  const FLOW_EXTS = new Set(['.js', '.cjs', '.mjs', '.jsx'])
27
+ const TS_EXTS = new Set(['.ts', '.cts', '.mts', '.tsx'])
30
28
 
31
29
  // Required lazily so non-JS bundlers don't load the native oxc parser.
32
30
  let _parser
33
- function getParser() {
31
+ export function getParser() {
34
32
  _parser ??= createRequire(import.meta.url)('oxc-parser')
35
33
  return _parser
36
34
  }
@@ -63,7 +61,10 @@ function formatForFile(file, host) {
63
61
  return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
64
62
  }
65
63
 
66
- function literalSpec(node) {
64
+ // The errors oxc reports for a parse, but its warnings and advice.
65
+ export const syntaxErrors = (parsed) => (parsed.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
66
+
67
+ export function literalSpec(node) {
67
68
  if (!node) return null
68
69
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value
69
70
  if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
@@ -111,6 +112,67 @@ function findCallSpecifiers(ast, push) {
111
112
  visit(ast)
112
113
  }
113
114
 
115
+ // The import specifiers `program` holds, as { kind, spec } ({ kind, dynamic: true } for a computed
116
+ // one). STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
117
+ // (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type` statement is
118
+ // erased whole, so no edge; a statement that merely lists inline `type` specifiers
119
+ // (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`, `export {} from`)
120
+ // -- still loads its module, so its edge is real. oxc's module records can't draw that line
121
+ // (`import type { A }` and `import { type A }` yield identical all-isType entries, and
122
+ // `export {} from` yields no entry at all), so the statements are read off the AST, where
123
+ // importKind/exportKind carry it.
124
+ function collectSpecifiers(program) {
125
+ const specs = []
126
+ for (const node of program.body) {
127
+ if (node.type === 'ImportDeclaration') {
128
+ if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
129
+ } else if (node.type === 'ExportNamedDeclaration') {
130
+ if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
131
+ } else if (node.type === 'ExportAllDeclaration') {
132
+ if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
133
+ }
134
+ }
135
+ findCallSpecifiers(program, (s) => specs.push(s))
136
+ return specs
137
+ }
138
+
139
+ // Whether `entries` (absolute paths) import TS sources by their OUTPUT names -- tsc's convention,
140
+ // which only --typescript resolves -- so the build can take --typescript as given: every entry is
141
+ // TypeScript, they hold at least one relative import naming a JS output (`./a.js`, or .jsx/.mjs/
142
+ // .cjs), and not one of those is on disk while the TS source it maps to is (`./a.ts`; see
143
+ // typescriptSiblings), outside node_modules as the resolver requires. Any part missing is no tell, and neither is an entry that doesn't parse as
144
+ // the scan parses it (Scan#parseFile; the scan reports it), nor one in node_modules, where
145
+ // --typescript maps nothing. Read off the entries alone, before the scan.
146
+ export function importsTypescriptByOutputName(entries, host = diskHost) {
147
+ if (!entries.every((entry) => TS_EXTS.has(extname(entry)) && !inNodeModules(entry, host))) return false
148
+ // Outside the try, as in the scan: a missing oxc-parser is an env error, not an entry's.
149
+ getParser()
150
+ const scanner = new Scan({ host })
151
+ const targets = []
152
+ for (const entry of entries) {
153
+ let result
154
+ try {
155
+ result = scanner.parseFile(entry)
156
+ } catch {
157
+ return false // unreadable: the scan reports it
158
+ }
159
+ if (result.crash !== undefined || result.errors.length > 0) return false
160
+ for (const { spec } of collectSpecifiers(result.parsed.program)) {
161
+ if (spec?.startsWith('./') || spec?.startsWith('../')) {
162
+ const target = resolvePath(dirname(entry), spec)
163
+ if (typescriptSiblings(target).length > 0) targets.push(target)
164
+ }
165
+ }
166
+ }
167
+ // tsc's pick, the first TS source on disk, as the resolver maps it: none where it lies in node_modules.
168
+ const isSource = (file) => !isTypeDeclaration(file) && (host.stat(file)?.isFile() ?? false)
169
+ const mapsTo = (target) => {
170
+ const pick = typescriptSiblings(target).find(isSource)
171
+ return pick !== undefined && !inNodeModules(pick, host)
172
+ }
173
+ return targets.length > 0 && targets.every((target) => host.stat(target) === null && mapsTo(target))
174
+ }
175
+
114
176
  function condKey(set) {
115
177
  return [...set].join(', ')
116
178
  }
@@ -129,25 +191,25 @@ export class Scan {
129
191
  // off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
130
192
  // `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
131
193
  // `typescript`: retry a failed resolution with tsc's mapping (see #typescriptResolve), with
132
- // `typescriptPaths` (a loadTsconfigPaths matcher) adding tsconfig alias support; only consulted
133
- // on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling.
194
+ // `typescriptPaths` (a tsconfig paths matcher; see resolveTypescriptFallback) adding tsconfig alias support; only consulted
195
+ // on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling. Without it, the
196
+ // mapping only names the file a miss would resolve to under --typescript (see #typescriptCandidate).
197
+ // `typescriptResolve`: the custom `resolve`'s --typescript twin (same shape), consulted only for
198
+ // that hint; null when there is no custom resolver or `typescript` is on.
134
199
  // `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
135
200
  // are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
136
201
  // that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
137
202
  // `host`: the filesystem the walk reads and resolves through (@exodus/stasis-core/host).
138
- constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, resources = new Set(), host = diskHost } = {}) {
203
+ constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, typescriptResolve = null, resources = new Set(), host = diskHost } = {}) {
139
204
  this.extraConditions = [...conditions]
140
205
  this.customResolve = resolve
206
+ this.typescriptResolve = typescriptResolve
141
207
  this.jsx = jsx
142
208
  this.flow = flow
143
209
  this.typescript = typescript
144
210
  this.typescriptPaths = typescriptPaths
145
211
  this.resources = resources
146
212
  this.host = host
147
- // --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
148
- // parsed (not left as opaque leaves) and queued when reached. Off by default the base sets apply.
149
- this.scriptExts = jsx ? new Set([...SCRIPT_EXTS, ...JSX_FILE_EXTS]) : SCRIPT_EXTS
150
- this.resolvableExts = jsx ? new Set([...RESOLVABLE_EXTS, ...JSX_FILE_EXTS]) : RESOLVABLE_EXTS
151
213
  }
152
214
 
153
215
  // A reached file whose extension/filename is in the --resources allowlist: carried as an opaque
@@ -201,7 +263,6 @@ export class Scan {
201
263
  #typescriptResolve(parentFile, spec, conditions) {
202
264
  const hit = resolveTypescriptFallback(parentFile, spec, {
203
265
  conditions,
204
- tsx: this.jsx,
205
266
  paths: this.typescriptPaths,
206
267
  host: this.host,
207
268
  })
@@ -213,6 +274,16 @@ export class Scan {
213
274
  }
214
275
  }
215
276
 
277
+ // The file: URL a miss would resolve to under --typescript, or null: a hint for the unresolved
278
+ // report only, never an edge. A custom resolver asks its --typescript twin; the built-in one
279
+ // tsc's mapping, as #typescriptResolve gives it under --typescript.
280
+ #typescriptCandidate(parentFile, spec, conditions) {
281
+ if (this.typescript) return null
282
+ if (this.customResolve) return this.typescriptResolve?.(parentFile, spec, conditions)?.url ?? null
283
+ const hit = this.#typescriptResolve(parentFile, spec, conditions)
284
+ return hit == null ? null : pathToFileURL(hit).toString()
285
+ }
286
+
216
287
  // Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
217
288
  // missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
218
289
  // so plain-JS files never reach it. Returns the rewritten source, or null when the transform
@@ -229,25 +300,12 @@ export class Scan {
229
300
  }
230
301
  }
231
302
 
232
- #scanFile(url, queue) {
233
- const file = fileURLToPath(url)
303
+ // Parse `file` (a script extension) as Node loads it: under the format its name and package
304
+ // declare, or for a typeless .js/.ts the one Node detects from its syntax. -> { format, parsed,
305
+ // errors } (`errors`: what the parse still reports), or { declared, crash } where the parser
306
+ // itself threw on the file, salvaging nothing.
307
+ parseFile(file) {
234
308
  const ext = extname(file)
235
- if (ext === '.json') {
236
- this.files.set(url, { format: 'json', edges: [] })
237
- return
238
- }
239
- if (this.#isResource(file)) {
240
- // Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
241
- // the authoritative signal (both bundle builders key off it); the concrete format
242
- // (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
243
- this.files.set(url, { format: null, edges: [], resource: true })
244
- return
245
- }
246
- if (!this.scriptExts.has(ext)) {
247
- this.files.set(url, { format: null, edges: [] })
248
- return
249
- }
250
-
251
309
  // declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
252
310
  const declared = formatForFile(file, this.host)
253
311
  const src = this.host.readFile(file).toString('utf8')
@@ -259,14 +317,15 @@ export class Scan {
259
317
  // force the JSX variant when opted in (`--jsx`) to parse React Native's JSX-in-.js source.
260
318
  const lang = this.jsx && JSX_EXTS.has(ext) ? 'jsx' : undefined
261
319
  const parseOptions = (sourceType) => (lang ? { sourceType, lang } : { sourceType })
320
+ // `commonjs`, not `script`: Node runs CJS inside its module wrapper function, where a top-level
321
+ // `return` or `new.target` is legal; oxc's `commonjs` accepts both and is otherwise `script`.
262
322
  const baseSourceType = declared === null ? 'unambiguous'
263
- : declared.startsWith('module') ? 'module' : 'script'
323
+ : declared.startsWith('module') ? 'module' : 'commonjs'
264
324
  let parsed
265
325
  try {
266
326
  parsed = parser.parseSync(file, src, parseOptions(baseSourceType))
267
327
  } catch (cause) {
268
- this.#recordParseError(url, declared, cause.message, false)
269
- return
328
+ return { declared, crash: cause.message }
270
329
  }
271
330
 
272
331
  // Match Node's syntax detection for typeless packages: ESM syntax picks the module variant
@@ -280,15 +339,15 @@ export class Scan {
280
339
 
281
340
  // oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
282
341
  // Warning/Advice severities aren't real parse errors.
283
- const nonWarning = (p) => (p.errors ?? []).filter((e) => e.severity !== 'Warning' && e.severity !== 'Advice')
284
- let errors = nonWarning(parsed)
342
+ let errors = syntaxErrors(parsed)
285
343
 
286
- // oxc's hasModuleSyntax misses top-level await, so a TLA-only ESM file parses here as a
287
- // broken script; mirror Node by retrying as module and preferring a clean module parse.
344
+ // oxc's `unambiguous` mode counts top-level await as module syntax (since 0.109; before, a
345
+ // TLA-only ESM file parsed here as a broken script). Keep mirroring Node for any script parse
346
+ // that still fails: retry as module and prefer a clean module parse.
288
347
  if (errors.length > 0 && declared === null && !format.startsWith('module')) {
289
348
  try {
290
349
  const asModule = parser.parseSync(file, src, parseOptions('module'))
291
- if (nonWarning(asModule).length === 0) {
350
+ if (syntaxErrors(asModule).length === 0) {
292
351
  parsed = asModule
293
352
  format = `module${tsSuffix}`
294
353
  errors = []
@@ -296,6 +355,19 @@ export class Scan {
296
355
  } catch { /* keep the script parse and its recorded errors */ }
297
356
  }
298
357
 
358
+ // A typeless file Node detects as CJS runs inside the module wrapper too, but `unambiguous`
359
+ // parses it as a plain script, so a top-level `return` still fails there: retry as `commonjs`
360
+ // and prefer a clean parse (the detected commonjs format stands).
361
+ if (errors.length > 0 && declared === null && !format.startsWith('module')) {
362
+ try {
363
+ const asCommonjs = parser.parseSync(file, src, parseOptions('commonjs'))
364
+ if (syntaxErrors(asCommonjs).length === 0) {
365
+ parsed = asCommonjs
366
+ errors = []
367
+ }
368
+ } catch { /* keep the script parse and its recorded errors */ }
369
+ }
370
+
299
371
  // --flow: oxc parses JS/TS but not Flow, so a Flow-typed .js/.cjs/.mjs file lands here with
300
372
  // parse errors. Only now -- after a raw oxc parse actually failed -- strip Flow types and
301
373
  // re-parse, so plain-JS files never pay the transform cost. Only the parse input is rewritten;
@@ -307,7 +379,7 @@ export class Scan {
307
379
  if (flowSrc !== null) {
308
380
  try {
309
381
  const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
310
- if (nonWarning(asFlow).length === 0) {
382
+ if (syntaxErrors(asFlow).length === 0) {
311
383
  parsed = asFlow
312
384
  format = declared ?? detectedFormat(parsed)
313
385
  errors = []
@@ -316,6 +388,35 @@ export class Scan {
316
388
  }
317
389
  }
318
390
 
391
+ return { format, parsed, errors }
392
+ }
393
+
394
+ #scanFile(url, queue) {
395
+ const file = fileURLToPath(url)
396
+ const ext = extname(file)
397
+ if (ext === '.json') {
398
+ this.files.set(url, { format: 'json', edges: [] })
399
+ return
400
+ }
401
+ if (this.#isResource(file)) {
402
+ // Allowlisted asset: carry it as a resource with no parse and no edges. `resource: true` is
403
+ // the authoritative signal (both bundle builders key off it); the concrete format
404
+ // (resource vs resource:base64) is byte-derived by the builder, so leave it unset here.
405
+ this.files.set(url, { format: null, edges: [], resource: true })
406
+ return
407
+ }
408
+ if (!SCRIPT_EXTS.has(ext)) {
409
+ this.files.set(url, { format: null, edges: [] })
410
+ return
411
+ }
412
+
413
+ const result = this.parseFile(file)
414
+ if (result.crash !== undefined) {
415
+ this.#recordParseError(url, result.declared, result.crash, false)
416
+ return
417
+ }
418
+ const { format, parsed, errors } = result
419
+
319
420
  let parseError
320
421
  if (errors.length > 0) {
321
422
  parseError = errors.map((e) => e.message).join('; ')
@@ -328,25 +429,7 @@ export class Scan {
328
429
  this.parseErrors.push({ url, format, message: parseError, recovered: true })
329
430
  }
330
431
 
331
- const specs = []
332
- // STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
333
- // (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type`
334
- // statement is erased whole, so no edge; a statement that merely lists inline `type`
335
- // specifiers (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`,
336
- // `export {} from`) -- still loads its module, so its edge is real. oxc's module records
337
- // can't draw that line (`import type { A }` and `import { type A }` yield identical
338
- // all-isType entries, and `export {} from` yields no entry at all), so the statements are
339
- // read off the AST, where importKind/exportKind carry it.
340
- for (const node of parsed.program.body) {
341
- if (node.type === 'ImportDeclaration') {
342
- if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
343
- } else if (node.type === 'ExportNamedDeclaration') {
344
- if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
345
- } else if (node.type === 'ExportAllDeclaration') {
346
- if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
347
- }
348
- }
349
- findCallSpecifiers(parsed.program, (s) => specs.push(s))
432
+ const specs = collectSpecifiers(parsed.program)
350
433
 
351
434
  // The edge KIND picks the resolution context, not the parent's format: createRequire()d
352
435
  // require() inside ESM resolves under REQUIRE conditions, literal import() inside CJS under
@@ -359,11 +442,13 @@ export class Scan {
359
442
  edges.push({ ...s, child: childURL })
360
443
  if (!specMaps.has(key)) specMaps.set(key, new Map())
361
444
  specMaps.get(key).set(s.spec, childURL)
362
- if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
445
+ if (RESOLVABLE_EXTS.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
363
446
  }
364
- const addUnresolved = (s, reason) => {
447
+ // Records the file --typescript would land the miss on too (typescriptURL), resolved as the miss was.
448
+ const addUnresolved = (s, reason, conditions) => {
365
449
  edges.push({ ...s, error: reason })
366
- this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason })
450
+ const typescriptURL = this.#typescriptCandidate(file, s.spec, conditions)
451
+ this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason, ...(typescriptURL ? { typescriptURL } : {}) })
367
452
  }
368
453
 
369
454
  for (const s of specs) {
@@ -386,7 +471,7 @@ export class Scan {
386
471
  } else if (r?.url) {
387
472
  addChild(s, key, r.url, fileURLToPath(r.url))
388
473
  } else {
389
- addUnresolved(s, 'MODULE_NOT_FOUND')
474
+ addUnresolved(s, 'MODULE_NOT_FOUND', conditions)
390
475
  }
391
476
  continue
392
477
  }
@@ -402,7 +487,7 @@ export class Scan {
402
487
  // stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
403
488
  if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
404
489
  if (childPath == null) {
405
- addUnresolved(s, cause.code ?? cause.message)
490
+ addUnresolved(s, cause.code ?? cause.message, conditions)
406
491
  continue
407
492
  }
408
493
  }
@@ -440,7 +525,7 @@ export class Scan {
440
525
  ),
441
526
  ])
442
527
  ),
443
- unresolved: this.unresolved.map(({ parentURL, ...rest }) => ({ parent: rel(parentURL), ...rest })),
528
+ unresolved: this.unresolved.map(({ parentURL, typescriptURL, ...rest }) => ({ parent: rel(parentURL), ...rest, ...(typescriptURL ? { typescript: rel(typescriptURL) } : {}) })),
444
529
  parseErrors: this.parseErrors.map(({ url, ...rest }) => ({ file: rel(url), ...rest })),
445
530
  }
446
531
  }
@@ -9,7 +9,7 @@ import { foundrySourceDir } from '../loaders/foundry.js'
9
9
  import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
10
10
  import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
11
11
 
12
- const JS = /\.[cm]?[jt]s$/u
12
+ const JS = /\.(?:[cm]?[jt]s|[jt]sx)$/u
13
13
  // Node's own conditions for require() and for import, which the build's are added to.
14
14
  const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
15
15
 
@@ -21,7 +21,7 @@ const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['im
21
21
  // fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
22
22
  // for require() and for import, with the conditions the build adds (the RN ones under `metro`); and
23
23
  // each `bin`. Only JS files in `dir` that are there, named from within it; none without a package.json.
24
- function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], jsx = false, typescript = false } = {}) {
24
+ function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], typescript = false } = {}) {
25
25
  const real = host.realpath(dir)
26
26
  const manifest = join(real, 'package.json')
27
27
  const pkg = readJson(manifest, host)
@@ -32,7 +32,7 @@ function packageEntries(host, dir, { conditions = [], mainFields, metro = false,
32
32
  if (rel !== '' && !relativeEscapes(rel) && JS.test(rel)) found.add(rel)
33
33
  }
34
34
  const passes = metro || mainFields !== undefined
35
- ? (metro ? platforms : [null]).map((platform) => fieldResolverFor(platform, { mainFields, metro, conditions, jsx, typescript, host }))
35
+ ? (metro ? platforms : [null]).map((platform) => fieldResolverFor(platform, { mainFields, metro, conditions, typescript, host }))
36
36
  : [{ extras: conditions, mainFields: ['main'] }]
37
37
  // Node's resolution of `specifier`, else under `typescript` the miss `mapped` as tsc maps it; a
38
38
  // real path either way, or undefined.
@@ -49,7 +49,7 @@ function packageEntries(host, dir, { conditions = [], mainFields, metro = false,
49
49
  const entry = fields.map((name) => pkg[name]).find((value) => typeof value === 'string' && value !== '')
50
50
  if (entry !== undefined && posixPathEscapes(entry)) continue
51
51
  if (resolver === undefined) {
52
- add(viaNode('./', NODE_CONDITIONS[0], extras, (set) => resolveTypescriptFallback(manifest, './', { conditions: set, tsx: jsx, host })))
52
+ add(viaNode('./', NODE_CONDITIONS[0], extras, (set) => resolveTypescriptFallback(manifest, './', { conditions: set, host })))
53
53
  } else {
54
54
  // A real path, as Node's resolution gives and the scan takes.
55
55
  const hit = resolver(manifest, './')
@@ -63,7 +63,7 @@ function packageEntries(host, dir, { conditions = [], mainFields, metro = false,
63
63
  if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
64
64
  const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
65
65
  for (const { extras } of passes) {
66
- for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, tsx: jsx, host })))
66
+ for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, host })))
67
67
  }
68
68
  }
69
69
  const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
@@ -8,7 +8,7 @@ import { createClient } from '@preventive/upstream/github.js'
8
8
  import { vfsFromEntries } from '@preventive/vfs'
9
9
  import { buildVfsBundle } from '../cmd/bundle.js'
10
10
  import { KINDS, checkAhead } from './entries.js'
11
- import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfileRoot, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
11
+ import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
12
12
 
13
13
  // As upstream's tree verification bounds a tarball's unpacked size.
14
14
  const MAX_TAR_BYTES = 2 ** 30
@@ -166,23 +166,29 @@ async function repoTree(name, { github, sha, tag, directory, client, packageMana
166
166
  // A GitHub repo at a full commit, or the one `tag` names, the default branch's head without either,
167
167
  // as buildVfsBundle builds it: `directory` downloaded alone where it stands alone, else the whole
168
168
  // repo, and without a `packageManager`, the one whose lockfile installs it, where only one's does
169
- // (repoTree). Without `entries`, they are the ones suggestedEntries suggests. `repo` names the
170
- // commit and where the lockfile is, which the bundle's paths are relative to. Nothing is read from
171
- // disk: the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree
172
- // id either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
169
+ // (repoTree). Without `entries`, they are the ones suggestedEntries suggests. The bundle's paths are
170
+ // relative to `directory`, or for a JS bundle, to the innermost package directory at or above it
171
+ // holding every file it bundles (buildJsBundle's innermostRoot), which is the project's root where
172
+ // one is outside the others; `repo` names that directory and the commit. Nothing is read from disk:
173
+ // the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree id
174
+ // either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
173
175
  export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
174
176
  checkTarget('buildGitHubBundle', options)
177
+ // Its own, for a Soldeer git dependency too.
178
+ client ??= createClient({ token: null })
175
179
  const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
176
- const root = lockfileRoot(tree.host, tree.packageManager, tree.cwd, options.os)
177
- const at = tree.subtree ? posix.join(directory, (root ?? '/').slice(1)) : (root ?? '/').slice(1)
178
- const location = at === '' ? { root: true } : isValidRepoField('directory', at) ? { directory: at } : {}
179
180
  let { entries } = options
180
181
  if (entries === undefined) {
181
182
  // As suggestedEntries suggests them, from the tree at hand.
182
183
  entries = tree.kind.entries(tree.host, tree.cwd, options)
183
184
  if (entries.length === 0) throw new Error(`${tree.where}: no entries given, and ${directory || 'the repo root'} ${tree.kind.none}`)
184
185
  }
185
- return buildVfsBundle({ ...options, entries, packageManager: tree.packageManager, vfs: tree.vfs, cwd: tree.cwd, repo: { github, commit: tree.sha, ...location } })
186
+ const repo = { github, commit: tree.sha }
187
+ const built = await buildVfsBundle({ ...options, entries, packageManager: tree.packageManager, vfs: tree.vfs, cwd: tree.cwd, innermostRoot: true, repo, client })
188
+ // The Vfs is the repo's tree, or the subtree at `directory`: a layout known, so its root is `''`.
189
+ const at = posix.join(tree.subtree ? directory : '.', posix.relative('/', built.root)).replace(/^\.$/u, '')
190
+ built.bundle.repo = { ...repo, ...(isValidRepoField('directory', at) ? { directory: at } : {}) }
191
+ return built
186
192
  }
187
193
 
188
194
  // suggestedEntries of a GitHub repo, `{ github, sha, tag, directory, client, packageManager }`, as