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