@ttsc/unplugin 0.21.0 → 0.23.0

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.
@@ -5,6 +5,7 @@ var fs = require('node:fs');
5
5
  var os = require('node:os');
6
6
  var path = require('node:path');
7
7
  var ttsc = require('ttsc');
8
+ var pathIdentity = require('ttsc/path-identity');
8
9
  var tsconfigPaths = require('./tsconfigPaths.js');
9
10
 
10
11
  /**
@@ -12,6 +13,11 @@ var tsconfigPaths = require('./tsconfigPaths.js');
12
13
  * {@link beginTtscTransformBuild} before transforms begin.
13
14
  */
14
15
  const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet();
16
+ function createHostPathIdentityContext() {
17
+ return pathIdentity.createFilesystemPathIdentityContext({
18
+ throwOnRealpathError: false,
19
+ });
20
+ }
15
21
  /** Create an empty persistent transform cache. */
16
22
  function createTtscTransformCache() {
17
23
  return new Map();
@@ -39,8 +45,6 @@ function resetTtscTransformCache(cache) {
39
45
  cache.clear();
40
46
  BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
41
47
  }
42
- /** Cached case-insensitivity probes for existing macOS filesystem locations. */
43
- const CASE_INSENSITIVE_FILESYSTEMS = new Map();
44
48
  /**
45
49
  * Apply the ttsc plugin transform to a single source file.
46
50
  *
@@ -100,7 +104,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
100
104
  // A file the plugin declared volatile must never be served from the
101
105
  // cache: its output depends on non-file inputs, so the input-hash
102
106
  // snapshot cannot prove freshness. Fall through to a fresh transform.
103
- !isVolatileFile({
107
+ !isVolatileFile(envelopeDerivation(cached), {
104
108
  file,
105
109
  projectRoot: cached.projectRoot,
106
110
  result: cached.result,
@@ -157,7 +161,7 @@ async function transformTtsc(id, source, options, aliases, cache, hooks) {
157
161
  });
158
162
  notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
159
163
  markCachedSourceServed(cached, file);
160
- if (isVolatileFile({ file, projectRoot, result })) {
164
+ if (isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })) {
161
165
  hooks?.markVolatile?.();
162
166
  }
163
167
  return createTransformResult(source, code);
@@ -207,6 +211,105 @@ function evictGeneration(cache, key, generation) {
207
211
  cache.delete(key);
208
212
  }
209
213
  }
214
+ /**
215
+ * Derivation states keyed by the compiler result object. One result object is
216
+ * produced by one compile against one project root, so the root captured at
217
+ * build time is the only root the state ever sees.
218
+ */
219
+ const ENVELOPE_DERIVATIONS = new WeakMap();
220
+ /** Return the derivation state of `props.result`, building it on first use. */
221
+ function envelopeDerivation(props) {
222
+ const existing = ENVELOPE_DERIVATIONS.get(props.result);
223
+ if (existing !== undefined) {
224
+ return existing;
225
+ }
226
+ const created = {
227
+ identityContext: createHostPathIdentityContext(),
228
+ identities: new Map(),
229
+ watchInputs: new Map(),
230
+ };
231
+ ENVELOPE_DERIVATIONS.set(props.result, created);
232
+ return created;
233
+ }
234
+ /**
235
+ * Build the reference-graph indexes of one envelope on first watch-input
236
+ * derivation. Malformed sections are dropped member by member, mirroring the
237
+ * historical per-delivery scan.
238
+ */
239
+ function envelopeGraphIndexes(state, props) {
240
+ if (state.graph !== undefined) {
241
+ return state.graph;
242
+ }
243
+ const built = {
244
+ edges: new Map(),
245
+ spellings: new Map(),
246
+ candidates: [],
247
+ globals: [],
248
+ configs: [],
249
+ };
250
+ const graph = props.result.type === "exception" ? undefined : props.result.graph;
251
+ if (graph !== undefined) {
252
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
253
+ if (!Array.isArray(targets)) {
254
+ continue;
255
+ }
256
+ const absolute = path.resolve(props.projectRoot, source);
257
+ const identity = derivationIdentity(state, absolute);
258
+ built.spellings.set(identity, absolute);
259
+ const entries = built.edges.get(identity) ?? [];
260
+ entries.push(...targets
261
+ .filter((target) => typeof target === "string" && target.length !== 0)
262
+ .map((target) => path.resolve(props.projectRoot, target)));
263
+ built.edges.set(identity, entries);
264
+ }
265
+ built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
266
+ built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
267
+ for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
268
+ if (!Array.isArray(candidates)) {
269
+ continue;
270
+ }
271
+ built.candidates.push({
272
+ source: derivationIdentity(state, path.resolve(props.projectRoot, source)),
273
+ files: selectListedFiles(props.projectRoot, candidates),
274
+ });
275
+ }
276
+ }
277
+ state.graph = built;
278
+ return built;
279
+ }
280
+ /**
281
+ * {@link pathIdentityKey} memoized inside one envelope's derivation state.
282
+ * Callers always pass already-resolved absolute paths, so the input string is a
283
+ * stable memo key.
284
+ */
285
+ function derivationIdentity(state, file) {
286
+ const existing = state.identities.get(file);
287
+ if (existing !== undefined) {
288
+ return existing;
289
+ }
290
+ const identity = pathIdentityKey(file, state.identityContext);
291
+ state.identities.set(file, identity);
292
+ return identity;
293
+ }
294
+ /**
295
+ * Fold one envelope member list (`volatile`, `dependenciesComplete`) into an
296
+ * identity set. Members are keyed like `typescript`, so a project-relative and
297
+ * an absolute spelling of the same file share one identity; a malformed member
298
+ * is ignored rather than fatal.
299
+ */
300
+ function collectDeclaredIdentities(state, projectRoot, listed) {
301
+ const output = new Set();
302
+ if (!Array.isArray(listed)) {
303
+ return output;
304
+ }
305
+ for (const entry of listed) {
306
+ if (typeof entry !== "string" || entry.length === 0) {
307
+ continue;
308
+ }
309
+ output.add(derivationIdentity(state, path.resolve(projectRoot, entry)));
310
+ }
311
+ return output;
312
+ }
210
313
  /**
211
314
  * Forward every derived watch input for `file` to the adapter's `addWatchFile`
212
315
  * hook: the plugin-reported `dependencies[file]` list unioned with the
@@ -246,23 +349,44 @@ function notifyWatchInputs(hooks, props) {
246
349
  * volatile keeps the baseline: the two declarations contradict, so the
247
350
  * conservative one wins.
248
351
  *
249
- * Returns an empty list on exceptions.
352
+ * The derived list is a pure function of the envelope and the file's filesystem
353
+ * identity, so it is computed at most once per generation per file: sibling and
354
+ * repeated deliveries replay the per-envelope memo ({@link envelopeDerivation})
355
+ * instead of re-walking the graph. Returns an empty list on exceptions.
250
356
  */
251
357
  function selectWatchInputs(props) {
358
+ if (props.result.type === "exception") {
359
+ return [];
360
+ }
361
+ const state = envelopeDerivation(props);
362
+ const fileIdentity = derivationIdentity(state, props.file);
363
+ const memoized = state.watchInputs.get(fileIdentity);
364
+ if (memoized !== undefined) {
365
+ return memoized;
366
+ }
367
+ const derived = deriveWatchInputs(state, props, fileIdentity);
368
+ state.watchInputs.set(fileIdentity, derived);
369
+ return derived;
370
+ }
371
+ /** Compute one file's watch-input list over the shared per-envelope state. */
372
+ function deriveWatchInputs(state, props, fileIdentity) {
373
+ const graph = envelopeGraphIndexes(state, props);
252
374
  const output = [];
253
375
  const seen = new Set();
254
- const excluded = new Set(props.temporaryTsconfig === undefined
255
- ? [pathIdentityKey(props.file)]
256
- : [pathIdentityKey(props.file), pathIdentityKey(props.temporaryTsconfig)]);
376
+ const excluded = new Set([fileIdentity]);
377
+ if (props.temporaryTsconfig !== undefined) {
378
+ excluded.add(derivationIdentity(state, props.temporaryTsconfig));
379
+ }
257
380
  for (const absolute of [
258
381
  ...selectFileDependencies(props),
259
- ...selectGraphInputs({
382
+ ...selectGraphInputs(graph, state, {
260
383
  ...props,
261
- complete: declaresCompleteDependencies(props) && !isVolatileFile(props),
384
+ complete: declaresCompleteDependencies(state, props) &&
385
+ !isVolatileFile(state, props),
262
386
  }),
263
- ...selectResolutionCandidateInputs(props),
387
+ ...selectResolutionCandidateInputs(graph, state, props),
264
388
  ]) {
265
- const identity = pathIdentityKey(absolute);
389
+ const identity = derivationIdentity(state, absolute);
266
390
  if (excluded.has(identity) || seen.has(identity)) {
267
391
  continue;
268
392
  }
@@ -276,23 +400,23 @@ function selectWatchInputs(props) {
276
400
  * module reachable from `file`. They remain host-owned even when a plugin
277
401
  * declares `dependenciesComplete`: plugin code cannot vouch for a compiler
278
402
  * resolution change that occurs without any plugin input changing.
403
+ *
404
+ * Candidate entries and their source identities come from the shared
405
+ * per-envelope state, so one delivery scans only the candidates themselves
406
+ * instead of re-resolving every candidate source.
279
407
  */
280
- function selectResolutionCandidateInputs(props) {
281
- if (props.result.type === "exception") {
282
- return [];
283
- }
284
- const graph = props.result.graph;
285
- if (graph === undefined || graph.candidates === undefined) {
408
+ function selectResolutionCandidateInputs(graph, state, props) {
409
+ if (props.result.type === "exception" ||
410
+ props.result.graph?.candidates === undefined) {
286
411
  return [];
287
412
  }
288
- const reachable = new Set(selectReachableSources(props.projectRoot, props.file, graph).map(pathIdentityKey));
413
+ const reachable = new Set(selectReachableSources(graph, state, props.file).map((source) => derivationIdentity(state, source)));
289
414
  const output = [];
290
- for (const [source, candidates] of Object.entries(graph.candidates)) {
291
- if (!reachable.has(pathIdentityKey(path.resolve(props.projectRoot, source))) ||
292
- !Array.isArray(candidates)) {
415
+ for (const entry of graph.candidates) {
416
+ if (!reachable.has(entry.source)) {
293
417
  continue;
294
418
  }
295
- output.push(...selectListedFiles(props.projectRoot, candidates));
419
+ output.push(...entry.files);
296
420
  }
297
421
  return output;
298
422
  }
@@ -310,47 +434,33 @@ function selectResolutionCandidateInputs(props) {
310
434
  * `dependencies[file]` list the complete replacement for them. Returns an empty
311
435
  * list on exceptions or without a graph.
312
436
  */
313
- function selectGraphInputs(props) {
314
- if (props.result.type === "exception") {
315
- return [];
316
- }
317
- const graph = props.result.graph;
318
- if (graph === undefined) {
437
+ function selectGraphInputs(graph, state, props) {
438
+ if (props.result.type === "exception" || props.result.graph === undefined) {
319
439
  return [];
320
440
  }
321
441
  const output = [];
322
442
  if (!props.complete) {
323
- output.push(...selectReachableEdges(props.projectRoot, props.file, graph));
324
- output.push(...selectListedFiles(props.projectRoot, graph.globals));
443
+ output.push(...selectReachableEdges(graph, state, props.file));
444
+ output.push(...graph.globals);
325
445
  }
326
- output.push(...selectListedFiles(props.projectRoot, graph.configs));
446
+ output.push(...graph.configs);
327
447
  return output;
328
448
  }
329
449
  /**
330
450
  * Walk the reachability closure of the graph's direct `edges` from `file`,
331
451
  * returning the absolute path of every file reached (the starting file itself
332
- * excluded, even when a cycle points back at it).
452
+ * excluded, even when a cycle points back at it). Reads the shared per-envelope
453
+ * edge index instead of rebuilding it per delivery.
333
454
  */
334
- function selectReachableEdges(projectRoot, file, graph) {
335
- const edges = new Map();
336
- for (const [source, targets] of Object.entries(graph.edges ?? {})) {
337
- if (!Array.isArray(targets)) {
338
- continue;
339
- }
340
- const identity = pathIdentityKey(path.resolve(projectRoot, source));
341
- const entries = edges.get(identity) ?? [];
342
- entries.push(...targets
343
- .filter((target) => typeof target === "string" && target.length !== 0)
344
- .map((target) => path.resolve(projectRoot, target)));
345
- edges.set(identity, entries);
346
- }
455
+ function selectReachableEdges(graph, state, file) {
347
456
  const output = [];
348
- const visited = new Set([pathIdentityKey(file)]);
457
+ const visited = new Set([derivationIdentity(state, file)]);
349
458
  const queue = [file];
350
459
  while (queue.length !== 0) {
351
460
  const current = queue.pop();
352
- for (const target of edges.get(pathIdentityKey(current)) ?? []) {
353
- const identity = pathIdentityKey(target);
461
+ for (const target of graph.edges.get(derivationIdentity(state, current)) ??
462
+ []) {
463
+ const identity = derivationIdentity(state, target);
354
464
  if (visited.has(identity)) {
355
465
  continue;
356
466
  }
@@ -366,35 +476,21 @@ function selectReachableEdges(projectRoot, file, graph) {
366
476
  * including `file` itself. Resolution candidates belong to importers rather
367
477
  * than targets, so this is intentionally distinct from selectReachableEdges.
368
478
  */
369
- function selectReachableSources(projectRoot, file, graph) {
370
- const edges = new Map();
371
- const spellings = new Map();
372
- for (const [source, targets] of Object.entries(graph.edges ?? {})) {
373
- if (!Array.isArray(targets)) {
374
- continue;
375
- }
376
- const absolute = path.resolve(projectRoot, source);
377
- const identity = pathIdentityKey(absolute);
378
- spellings.set(identity, absolute);
379
- const entries = edges.get(identity) ?? [];
380
- entries.push(...targets
381
- .filter((target) => typeof target === "string" && target.length !== 0)
382
- .map((target) => path.resolve(projectRoot, target)));
383
- edges.set(identity, entries);
384
- }
479
+ function selectReachableSources(graph, state, file) {
385
480
  const output = [file];
386
- const visited = new Set([pathIdentityKey(file)]);
481
+ const visited = new Set([derivationIdentity(state, file)]);
387
482
  const queue = [file];
388
483
  while (queue.length !== 0) {
389
484
  const current = queue.pop();
390
- for (const target of edges.get(pathIdentityKey(current)) ?? []) {
391
- const identity = pathIdentityKey(target);
485
+ for (const target of graph.edges.get(derivationIdentity(state, current)) ??
486
+ []) {
487
+ const identity = derivationIdentity(state, target);
392
488
  if (visited.has(identity)) {
393
489
  continue;
394
490
  }
395
491
  visited.add(identity);
396
492
  queue.push(target);
397
- output.push(spellings.get(identity) ?? target);
493
+ output.push(graph.spellings.get(identity) ?? target);
398
494
  }
399
495
  }
400
496
  return output;
@@ -420,13 +516,15 @@ function selectListedFiles(projectRoot, listed) {
420
516
  /**
421
517
  * Report whether the plugin declared `file` volatile: its output depends on
422
518
  * non-file inputs (environment, time, network), so neither the project
423
- * transform cache nor a bundler's persistent cache may replay it.
519
+ * transform cache nor a bundler's persistent cache may replay it. Reads the
520
+ * per-envelope identity set instead of rescanning the member list.
424
521
  */
425
- function isVolatileFile(props) {
522
+ function isVolatileFile(state, props) {
426
523
  if (props.result.type === "exception") {
427
524
  return false;
428
525
  }
429
- return declaresFile(props.result.volatile, props);
526
+ const declared = (state.volatileFiles ??= collectDeclaredIdentities(state, props.projectRoot, props.result.volatile));
527
+ return declared.has(derivationIdentity(state, props.file));
430
528
  }
431
529
  /**
432
530
  * Report whether the envelope declared `dependencies[file]` complete, i.e. the
@@ -434,32 +532,18 @@ function isVolatileFile(props) {
434
532
  * itself and the universal config chain. Callers must still keep the baseline
435
533
  * for a file the same envelope declared volatile.
436
534
  */
437
- function declaresCompleteDependencies(props) {
535
+ function declaresCompleteDependencies(state, props) {
438
536
  if (props.result.type === "exception") {
439
537
  return false;
440
538
  }
441
- return declaresFile(props.result.dependenciesComplete, props);
442
- }
443
- /**
444
- * Report whether one of the envelope's transformed-file lists (`volatile`,
445
- * `dependenciesComplete`) names `file`. Members are keyed like `typescript`, so
446
- * a project-relative and an absolute spelling of the same file both match; a
447
- * malformed member is ignored rather than fatal.
448
- */
449
- function declaresFile(listed, props) {
450
- if (!Array.isArray(listed)) {
451
- return false;
452
- }
453
- return listed.some((entry) => typeof entry === "string" &&
454
- entry.length !== 0 &&
455
- pathIdentityKey(path.resolve(props.projectRoot, entry)) ===
456
- pathIdentityKey(props.file));
539
+ const declared = (state.dependenciesComplete ??= collectDeclaredIdentities(state, props.projectRoot, props.result.dependenciesComplete));
540
+ return declared.has(derivationIdentity(state, props.file));
457
541
  }
458
542
  /**
459
543
  * Extract the absolute, deduplicated dependency list for a single file from the
460
544
  * compiler result. Mirrors {@link selectTransformedSource}'s key lookup: fast
461
- * project-relative match first, then a resolve-based scan. Returns an empty
462
- * list on exceptions or when the plugin reported nothing.
545
+ * project-relative match first, then a per-envelope identity index. Returns an
546
+ * empty list on exceptions or when the plugin reported nothing.
463
547
  */
464
548
  function selectFileDependencies(props) {
465
549
  if (props.result.type === "exception") {
@@ -469,29 +553,26 @@ function selectFileDependencies(props) {
469
553
  if (dependencies === undefined) {
470
554
  return [];
471
555
  }
472
- const key = toProjectKey(props.projectRoot, props.file);
556
+ const state = envelopeDerivation(props);
557
+ const key = toProjectKey(props.projectRoot, props.file, state.identityContext);
473
558
  let entries = dependencies[key];
474
559
  if (entries === undefined) {
475
- for (const [candidate, candidateEntries] of Object.entries(dependencies)) {
476
- if (pathIdentityKey(path.resolve(props.projectRoot, candidate)) ===
477
- pathIdentityKey(props.file)) {
478
- entries = candidateEntries;
479
- break;
480
- }
481
- }
560
+ const index = (state.dependencyIndex ??= createEnvelopeKeyIndex(state, props.projectRoot, dependencies));
561
+ entries = index.get(derivationIdentity(state, props.file));
482
562
  }
483
563
  if (!Array.isArray(entries)) {
484
564
  return [];
485
565
  }
486
566
  const output = [];
487
567
  const seen = new Set();
568
+ const fileIdentity = derivationIdentity(state, props.file);
488
569
  for (const entry of entries) {
489
570
  if (typeof entry !== "string" || entry.length === 0) {
490
571
  continue;
491
572
  }
492
573
  const absolute = path.resolve(props.projectRoot, entry);
493
- const identity = pathIdentityKey(absolute);
494
- if (identity === pathIdentityKey(props.file) || seen.has(identity)) {
574
+ const identity = derivationIdentity(state, absolute);
575
+ if (identity === fileIdentity || seen.has(identity)) {
495
576
  continue;
496
577
  }
497
578
  seen.add(identity);
@@ -499,6 +580,21 @@ function selectFileDependencies(props) {
499
580
  }
500
581
  return output;
501
582
  }
583
+ /**
584
+ * Build a first-match identity index over one envelope key map (`typescript`,
585
+ * `dependencies`), mirroring the historical per-delivery scan that returned the
586
+ * first entry whose resolved key matched by filesystem identity.
587
+ */
588
+ function createEnvelopeKeyIndex(state, projectRoot, keyed) {
589
+ const index = new Map();
590
+ for (const [candidate, value] of Object.entries(keyed)) {
591
+ const identity = derivationIdentity(state, path.resolve(projectRoot, candidate));
592
+ if (!index.has(identity)) {
593
+ index.set(identity, value);
594
+ }
595
+ }
596
+ return index;
597
+ }
502
598
  /**
503
599
  * Strip a query string or hash fragment from a bundler module id.
504
600
  *
@@ -554,14 +650,16 @@ function createTransformResult(source, code) {
554
650
  * agree on the key universe.
555
651
  */
556
652
  function matchesCachedSource(cached, file, source, buildScoped) {
557
- const currentKey = toProjectKey(cached.projectRoot, file);
653
+ const identities = envelopeDerivation(cached).identityContext;
654
+ const currentKey = toProjectKey(cached.projectRoot, file, identities);
558
655
  if (cached.inputHashes[currentKey] !== hashText(source)) {
559
656
  return false;
560
657
  }
561
- if (buildScoped && !cached.servedFiles?.has(pathIdentityKey(file))) {
658
+ if (buildScoped &&
659
+ !cached.servedFiles?.has(pathIdentityKey(file, identities))) {
562
660
  return true;
563
661
  }
564
- const currentHashes = collectProjectInputHashes(cached.projectRoot);
662
+ const currentHashes = collectProjectInputHashes(cached.projectRoot, identities);
565
663
  currentHashes[currentKey] = hashText(source);
566
664
  if (!sameHashes(cached.inputHashes, currentHashes)) {
567
665
  return false;
@@ -579,7 +677,7 @@ function matchesCachedSource(cached, file, source, buildScoped) {
579
677
  }
580
678
  /** Record a successfully selected module as delivered by this generation. */
581
679
  function markCachedSourceServed(cached, file) {
582
- (cached.servedFiles ??= new Set()).add(pathIdentityKey(file));
680
+ (cached.servedFiles ??= new Set()).add(pathIdentityKey(file, envelopeDerivation(cached).identityContext));
583
681
  }
584
682
  /**
585
683
  * Build the input-hash snapshot stored alongside a fresh compiler result.
@@ -595,9 +693,11 @@ function markCachedSourceServed(cached, file) {
595
693
  * them here would make every snapshot comparison fail and the cache never hit.
596
694
  */
597
695
  function collectInputHashes(props) {
598
- const hashes = collectProjectInputHashes(props.projectRoot);
696
+ const identities = createHostPathIdentityContext();
697
+ const hashes = collectProjectInputHashes(props.projectRoot, identities);
599
698
  // Overlay the in-memory source so unsaved edits invalidate the cache.
600
- hashes[toProjectKey(props.projectRoot, props.currentFile)] = hashText(props.currentSource);
699
+ hashes[toProjectKey(props.projectRoot, props.currentFile, identities)] =
700
+ hashText(props.currentSource);
601
701
  return hashes;
602
702
  }
603
703
  /**
@@ -606,11 +706,11 @@ function collectInputHashes(props) {
606
706
  * slash path. Exported so hosts without a per-build boundary (`@ttsc/metro`)
607
707
  * can fold the identical input universe into their own cache fingerprints.
608
708
  */
609
- function collectProjectInputHashes(projectRoot) {
709
+ function collectProjectInputHashes(projectRoot, identities = createHostPathIdentityContext()) {
610
710
  const hashes = {};
611
711
  for (const file of listProjectInputFiles(projectRoot)) {
612
712
  try {
613
- hashes[toProjectKey(projectRoot, file)] = hashText(fs.readFileSync(file));
713
+ hashes[toProjectKey(projectRoot, file, identities)] = hashText(fs.readFileSync(file));
614
714
  }
615
715
  catch {
616
716
  // File watchers may observe a transform while another process is moving
@@ -664,8 +764,13 @@ function listProjectInputFiles(root) {
664
764
  * Missing paths and files reached through symlinks or Windows junctions are
665
765
  * out-of-walk inputs that only the reference graph can prove relevant.
666
766
  */
667
- function isProjectWalkPath(root, file) {
668
- const relative = path.relative(pathIdentityKey(root), pathIdentityKey(file));
767
+ function isProjectWalkPath(root, file, identities = createHostPathIdentityContext()) {
768
+ if (!identities.isWithin(root, file)) {
769
+ return false;
770
+ }
771
+ const rootKey = pathIdentityKey(root, identities);
772
+ const fileKey = pathIdentityKey(file, identities);
773
+ const relative = fileKey.slice(rootKey.length).replace(/^[/\\]+/, "");
669
774
  if (relative.length === 0 ||
670
775
  relative === ".." ||
671
776
  relative.startsWith(`..${path.sep}`) ||
@@ -707,8 +812,9 @@ function isProjectWalkPath(root, file) {
707
812
  */
708
813
  function collectExternalInputHashes(paths) {
709
814
  const hashes = {};
815
+ const identities = createHostPathIdentityContext();
710
816
  for (const file of paths) {
711
- const identity = pathIdentityKey(file);
817
+ const identity = pathIdentityKey(file, identities);
712
818
  if (identity in hashes) {
713
819
  continue;
714
820
  }
@@ -744,6 +850,7 @@ function selectExternalInputPaths(props) {
744
850
  return [];
745
851
  }
746
852
  const members = [];
853
+ const identities = createHostPathIdentityContext();
747
854
  const resolutionCandidates = new Set();
748
855
  const graph = props.result.graph;
749
856
  if (graph !== undefined) {
@@ -768,7 +875,7 @@ function selectExternalInputPaths(props) {
768
875
  }
769
876
  const absolute = path.resolve(props.projectRoot, candidate);
770
877
  members.push(candidate);
771
- resolutionCandidates.add(pathIdentityKey(absolute));
878
+ resolutionCandidates.add(pathIdentityKey(absolute, identities));
772
879
  }
773
880
  }
774
881
  }
@@ -779,7 +886,7 @@ function selectExternalInputPaths(props) {
779
886
  }
780
887
  const excluded = props.temporaryTsconfig === undefined
781
888
  ? undefined
782
- : pathIdentityKey(props.temporaryTsconfig);
889
+ : pathIdentityKey(props.temporaryTsconfig, identities);
783
890
  const output = [];
784
891
  const seen = new Set();
785
892
  for (const member of members) {
@@ -787,11 +894,12 @@ function selectExternalInputPaths(props) {
787
894
  continue;
788
895
  }
789
896
  const absolute = path.resolve(props.projectRoot, member);
790
- const identity = pathIdentityKey(absolute);
897
+ const identity = pathIdentityKey(absolute, identities);
791
898
  const missingCandidate = resolutionCandidates.has(identity) && !fs.existsSync(absolute);
792
899
  if (identity === excluded ||
793
900
  seen.has(identity) ||
794
- (!missingCandidate && isProjectWalkPath(props.projectRoot, absolute))) {
901
+ (!missingCandidate &&
902
+ isProjectWalkPath(props.projectRoot, absolute, identities))) {
795
903
  continue;
796
904
  }
797
905
  seen.add(identity);
@@ -1099,17 +1207,18 @@ function selectTransformedSource(props) {
1099
1207
  throw new Error(formatDiagnostics(props.result.diagnostics));
1100
1208
  }
1101
1209
  // Fast path: the compiler key matches the normalised project-relative path.
1102
- const key = toProjectKey(props.projectRoot, props.file);
1210
+ const state = envelopeDerivation(props);
1211
+ const key = toProjectKey(props.projectRoot, props.file, state.identityContext);
1103
1212
  const direct = props.result.typescript[key];
1104
1213
  if (direct !== undefined) {
1105
1214
  return direct;
1106
1215
  }
1107
- // Slow path: resolve each candidate to an absolute path for comparison.
1108
- for (const [candidate, source] of Object.entries(props.result.typescript)) {
1109
- if (pathIdentityKey(path.resolve(props.projectRoot, candidate)) ===
1110
- pathIdentityKey(props.file)) {
1111
- return source;
1112
- }
1216
+ // Slow path: the first-match identity index of the envelope's `typescript`
1217
+ // keys, built once per generation instead of scanned per delivery.
1218
+ const index = (state.outputIndex ??= createEnvelopeKeyIndex(state, props.projectRoot, props.result.typescript));
1219
+ const source = index.get(derivationIdentity(state, props.file));
1220
+ if (source !== undefined) {
1221
+ return source;
1113
1222
  }
1114
1223
  throw new Error(`ttsc transform did not return output for ${props.file}`);
1115
1224
  }
@@ -1196,62 +1305,21 @@ function resolveTsconfig(file, tsconfig) {
1196
1305
  }
1197
1306
  return path.resolve(process.cwd(), "tsconfig.json");
1198
1307
  }
1199
- function toProjectKey(root, file) {
1200
- return normalizePath(path.relative(pathIdentityKey(root), pathIdentityKey(file)));
1308
+ function toProjectKey(root, file, identities = createHostPathIdentityContext()) {
1309
+ const rootKey = pathIdentityKey(root, identities);
1310
+ const fileKey = pathIdentityKey(file, identities);
1311
+ if (!identities.isWithin(root, file)) {
1312
+ return normalizePath(fileKey);
1313
+ }
1314
+ return normalizePath(fileKey.slice(rootKey.length).replace(/^[/\\]+/, ""));
1201
1315
  }
1202
1316
  /**
1203
1317
  * Build a comparison key for a path without changing the spelling handed to a
1204
1318
  * filesystem or bundler. Windows is case-insensitive; macOS is probed per
1205
1319
  * existing filesystem location so case-sensitive volumes keep distinct paths.
1206
1320
  */
1207
- function pathIdentityKey(file) {
1208
- const absolute = path.resolve(file);
1209
- return filesystemIsCaseInsensitive(absolute)
1210
- ? absolute.toLowerCase()
1211
- : absolute;
1212
- }
1213
- function filesystemIsCaseInsensitive(file) {
1214
- if (process.platform === "win32") {
1215
- return true;
1216
- }
1217
- if (process.platform !== "darwin") {
1218
- return false;
1219
- }
1220
- let existing = file;
1221
- while (!fs.existsSync(existing)) {
1222
- const parent = path.dirname(existing);
1223
- if (parent === existing) {
1224
- return false;
1225
- }
1226
- existing = parent;
1227
- }
1228
- let resolved;
1229
- try {
1230
- resolved = fs.realpathSync.native(existing);
1231
- }
1232
- catch {
1233
- return false;
1234
- }
1235
- const cached = CASE_INSENSITIVE_FILESYSTEMS.get(resolved);
1236
- if (cached !== undefined) {
1237
- return cached;
1238
- }
1239
- const alternate = togglePathCase(resolved);
1240
- const insensitive = alternate !== undefined && fs.existsSync(alternate);
1241
- CASE_INSENSITIVE_FILESYSTEMS.set(resolved, insensitive);
1242
- return insensitive;
1243
- }
1244
- function togglePathCase(file) {
1245
- for (let index = file.length - 1; index >= 0; --index) {
1246
- const character = file[index];
1247
- if (character >= "a" && character <= "z") {
1248
- return `${file.slice(0, index)}${character.toUpperCase()}${file.slice(index + 1)}`;
1249
- }
1250
- if (character >= "A" && character <= "Z") {
1251
- return `${file.slice(0, index)}${character.toLowerCase()}${file.slice(index + 1)}`;
1252
- }
1253
- }
1254
- return undefined;
1321
+ function pathIdentityKey(file, identities = createHostPathIdentityContext()) {
1322
+ return identities.resolve(file).key;
1255
1323
  }
1256
1324
  function normalizePath(file) {
1257
1325
  return file.replace(/\\/g, "/");