@ttsc/unplugin 0.22.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.
@@ -7,6 +7,10 @@ import type {
7
7
  ITtscCompilerTransformation,
8
8
  } from "ttsc";
9
9
  import { TtscCompiler } from "ttsc";
10
+ import {
11
+ type FilesystemPathIdentityContext,
12
+ createFilesystemPathIdentityContext,
13
+ } from "ttsc/path-identity";
10
14
  import type { TransformResult } from "unplugin";
11
15
 
12
16
  import type { ResolvedTtscUnpluginOptions } from "./options";
@@ -113,6 +117,12 @@ export type TtscTransformCache = Map<
113
117
  */
114
118
  const BUILD_SCOPED_TRANSFORM_CACHES = new WeakSet<TtscTransformCache>();
115
119
 
120
+ function createHostPathIdentityContext(): FilesystemPathIdentityContext {
121
+ return createFilesystemPathIdentityContext({
122
+ throwOnRealpathError: false,
123
+ });
124
+ }
125
+
116
126
  /** Create an empty persistent transform cache. */
117
127
  export function createTtscTransformCache(): TtscTransformCache {
118
128
  return new Map();
@@ -143,9 +153,6 @@ export function resetTtscTransformCache(cache: TtscTransformCache): void {
143
153
  BUILD_SCOPED_TRANSFORM_CACHES.delete(cache);
144
154
  }
145
155
 
146
- /** Cached case-insensitivity probes for existing macOS filesystem locations. */
147
- const CASE_INSENSITIVE_FILESYSTEMS = new Map<string, boolean>();
148
-
149
156
  /**
150
157
  * Hooks the bundler adapter passes into {@link transformTtsc} so transform
151
158
  * side-channels (plugin-reported dependencies and host resolution candidates)
@@ -244,7 +251,7 @@ export async function transformTtsc(
244
251
  // A file the plugin declared volatile must never be served from the
245
252
  // cache: its output depends on non-file inputs, so the input-hash
246
253
  // snapshot cannot prove freshness. Fall through to a fresh transform.
247
- !isVolatileFile({
254
+ !isVolatileFile(envelopeDerivation(cached), {
248
255
  file,
249
256
  projectRoot: cached.projectRoot,
250
257
  result: cached.result,
@@ -308,7 +315,9 @@ export async function transformTtsc(
308
315
  });
309
316
  notifyWatchInputs(hooks, { file, projectRoot, result, temporaryTsconfig });
310
317
  markCachedSourceServed(cached, file);
311
- if (isVolatileFile({ file, projectRoot, result })) {
318
+ if (
319
+ isVolatileFile(envelopeDerivation(cached), { file, projectRoot, result })
320
+ ) {
312
321
  hooks?.markVolatile?.();
313
322
  }
314
323
  return createTransformResult(source, code);
@@ -377,6 +386,211 @@ function evictGeneration(
377
386
  }
378
387
  }
379
388
 
389
+ /**
390
+ * Per-envelope derivation state: every index the per-delivery paths need, built
391
+ * at most once and shared by all deliveries of one compiler result.
392
+ *
393
+ * Building the direct-edge index, the candidate entries, the declared-file
394
+ * identity sets, or the output/dependency key indexes per delivery costs
395
+ * O(envelope) `pathIdentityKey` computations per module — and each of those
396
+ * costs real path-resolution and case-semantics probes
397
+ * ({@link pathIdentityKey}). A graph-bearing envelope (typia >= 13.1.19) turned
398
+ * that into O(modules x edges) filesystem work per build, which is the
399
+ * samchon/ttsc#1007 stall. All deliveries of one generation share this state,
400
+ * so a delivery pays only its own reachability closure with memoized
401
+ * identities.
402
+ *
403
+ * Every index is lazy: a host that never wires `addWatchFile` and never misses
404
+ * a project-relative key pays nothing beyond the volatile/completeness
405
+ * membership sets, which are themselves built on first predicate use.
406
+ *
407
+ * Freshness matches the generation contract: every derivable path is a recorded
408
+ * project or external input, so persistent-mode validation already proves those
409
+ * paths unchanged on every non-build-scoped hit, and any change invalidates the
410
+ * generation — and this state with it. The `WeakMap` key is the envelope object
411
+ * itself, so the state dies when the generation does.
412
+ */
413
+ interface TtscEnvelopeDerivation {
414
+ /** One filesystem snapshot for every identity comparison in this envelope. */
415
+ readonly identityContext: FilesystemPathIdentityContext;
416
+ /** Memoized {@link pathIdentityKey} results, keyed by the exact input. */
417
+ readonly identities: Map<string, string>;
418
+ /**
419
+ * Lazily built reference-graph indexes, `undefined` until the first
420
+ * watch-input derivation. A host without an `addWatchFile` hook never pays
421
+ * the O(edges) build.
422
+ */
423
+ graph?: TtscEnvelopeGraphIndexes;
424
+ /**
425
+ * Lazily collected identities of the envelope's `volatile` member files,
426
+ * `undefined` until the first volatility predicate.
427
+ */
428
+ volatileFiles?: Set<string>;
429
+ /**
430
+ * Lazily collected identities of the envelope's `dependenciesComplete` member
431
+ * files, `undefined` until the first completeness predicate.
432
+ */
433
+ dependenciesComplete?: Set<string>;
434
+ /**
435
+ * Lazily built identity -> output source index of the `typescript` map (first
436
+ * match wins, mirroring the historical scan). `undefined` until the first
437
+ * project-relative key miss.
438
+ */
439
+ outputIndex?: Map<string, string>;
440
+ /**
441
+ * Lazily built identity -> `dependencies` entries index (first match wins,
442
+ * mirroring the historical scan). `undefined` until the first key miss.
443
+ */
444
+ dependencyIndex?: Map<string, unknown>;
445
+ /** Per-file memo of the final derived watch-input list. */
446
+ readonly watchInputs: Map<string, string[]>;
447
+ }
448
+
449
+ /** Reference-graph indexes shared by every watch-input derivation. */
450
+ interface TtscEnvelopeGraphIndexes {
451
+ /** Identity of each direct-edge source -> its resolved absolute targets. */
452
+ readonly edges: Map<string, string[]>;
453
+ /** Identity of each direct-edge source -> its absolute spelling. */
454
+ readonly spellings: Map<string, string>;
455
+ /** Resolution-candidate entries in envelope order, sources pre-identified. */
456
+ readonly candidates: { source: string; files: string[] }[];
457
+ /** Resolved absolute `graph.globals` and `graph.configs` members. */
458
+ readonly globals: string[];
459
+ readonly configs: string[];
460
+ }
461
+
462
+ /**
463
+ * Derivation states keyed by the compiler result object. One result object is
464
+ * produced by one compile against one project root, so the root captured at
465
+ * build time is the only root the state ever sees.
466
+ */
467
+ const ENVELOPE_DERIVATIONS = new WeakMap<
468
+ ITtscCompilerTransformation,
469
+ TtscEnvelopeDerivation
470
+ >();
471
+
472
+ /** Return the derivation state of `props.result`, building it on first use. */
473
+ function envelopeDerivation(props: {
474
+ projectRoot: string;
475
+ result: ITtscCompilerTransformation;
476
+ }): TtscEnvelopeDerivation {
477
+ const existing = ENVELOPE_DERIVATIONS.get(props.result);
478
+ if (existing !== undefined) {
479
+ return existing;
480
+ }
481
+ const created: TtscEnvelopeDerivation = {
482
+ identityContext: createHostPathIdentityContext(),
483
+ identities: new Map(),
484
+ watchInputs: new Map(),
485
+ };
486
+ ENVELOPE_DERIVATIONS.set(props.result, created);
487
+ return created;
488
+ }
489
+
490
+ /**
491
+ * Build the reference-graph indexes of one envelope on first watch-input
492
+ * derivation. Malformed sections are dropped member by member, mirroring the
493
+ * historical per-delivery scan.
494
+ */
495
+ function envelopeGraphIndexes(
496
+ state: TtscEnvelopeDerivation,
497
+ props: {
498
+ projectRoot: string;
499
+ result: ITtscCompilerTransformation;
500
+ },
501
+ ): TtscEnvelopeGraphIndexes {
502
+ if (state.graph !== undefined) {
503
+ return state.graph;
504
+ }
505
+ const built: TtscEnvelopeGraphIndexes = {
506
+ edges: new Map(),
507
+ spellings: new Map(),
508
+ candidates: [],
509
+ globals: [],
510
+ configs: [],
511
+ };
512
+ const graph =
513
+ props.result.type === "exception" ? undefined : props.result.graph;
514
+ if (graph !== undefined) {
515
+ for (const [source, targets] of Object.entries(graph.edges ?? {})) {
516
+ if (!Array.isArray(targets)) {
517
+ continue;
518
+ }
519
+ const absolute = path.resolve(props.projectRoot, source);
520
+ const identity = derivationIdentity(state, absolute);
521
+ built.spellings.set(identity, absolute);
522
+ const entries = built.edges.get(identity) ?? [];
523
+ entries.push(
524
+ ...targets
525
+ .filter(
526
+ (target): target is string =>
527
+ typeof target === "string" && target.length !== 0,
528
+ )
529
+ .map((target) => path.resolve(props.projectRoot, target)),
530
+ );
531
+ built.edges.set(identity, entries);
532
+ }
533
+ built.globals.push(...selectListedFiles(props.projectRoot, graph.globals));
534
+ built.configs.push(...selectListedFiles(props.projectRoot, graph.configs));
535
+ for (const [source, candidates] of Object.entries(graph.candidates ?? {})) {
536
+ if (!Array.isArray(candidates)) {
537
+ continue;
538
+ }
539
+ built.candidates.push({
540
+ source: derivationIdentity(
541
+ state,
542
+ path.resolve(props.projectRoot, source),
543
+ ),
544
+ files: selectListedFiles(props.projectRoot, candidates),
545
+ });
546
+ }
547
+ }
548
+ state.graph = built;
549
+ return built;
550
+ }
551
+
552
+ /**
553
+ * {@link pathIdentityKey} memoized inside one envelope's derivation state.
554
+ * Callers always pass already-resolved absolute paths, so the input string is a
555
+ * stable memo key.
556
+ */
557
+ function derivationIdentity(
558
+ state: TtscEnvelopeDerivation,
559
+ file: string,
560
+ ): string {
561
+ const existing = state.identities.get(file);
562
+ if (existing !== undefined) {
563
+ return existing;
564
+ }
565
+ const identity = pathIdentityKey(file, state.identityContext);
566
+ state.identities.set(file, identity);
567
+ return identity;
568
+ }
569
+
570
+ /**
571
+ * Fold one envelope member list (`volatile`, `dependenciesComplete`) into an
572
+ * identity set. Members are keyed like `typescript`, so a project-relative and
573
+ * an absolute spelling of the same file share one identity; a malformed member
574
+ * is ignored rather than fatal.
575
+ */
576
+ function collectDeclaredIdentities(
577
+ state: TtscEnvelopeDerivation,
578
+ projectRoot: string,
579
+ listed: unknown,
580
+ ): Set<string> {
581
+ const output = new Set<string>();
582
+ if (!Array.isArray(listed)) {
583
+ return output;
584
+ }
585
+ for (const entry of listed) {
586
+ if (typeof entry !== "string" || entry.length === 0) {
587
+ continue;
588
+ }
589
+ output.add(derivationIdentity(state, path.resolve(projectRoot, entry)));
590
+ }
591
+ return output;
592
+ }
593
+
380
594
  /**
381
595
  * Forward every derived watch input for `file` to the adapter's `addWatchFile`
382
596
  * hook: the plugin-reported `dependencies[file]` list unioned with the
@@ -425,7 +639,10 @@ function notifyWatchInputs(
425
639
  * volatile keeps the baseline: the two declarations contradict, so the
426
640
  * conservative one wins.
427
641
  *
428
- * Returns an empty list on exceptions.
642
+ * The derived list is a pure function of the envelope and the file's filesystem
643
+ * identity, so it is computed at most once per generation per file: sibling and
644
+ * repeated deliveries replay the per-envelope memo ({@link envelopeDerivation})
645
+ * instead of re-walking the graph. Returns an empty list on exceptions.
429
646
  */
430
647
  function selectWatchInputs(props: {
431
648
  file: string;
@@ -433,22 +650,49 @@ function selectWatchInputs(props: {
433
650
  result: ITtscCompilerTransformation;
434
651
  temporaryTsconfig?: string;
435
652
  }): string[] {
653
+ if (props.result.type === "exception") {
654
+ return [];
655
+ }
656
+ const state = envelopeDerivation(props);
657
+ const fileIdentity = derivationIdentity(state, props.file);
658
+ const memoized = state.watchInputs.get(fileIdentity);
659
+ if (memoized !== undefined) {
660
+ return memoized;
661
+ }
662
+ const derived = deriveWatchInputs(state, props, fileIdentity);
663
+ state.watchInputs.set(fileIdentity, derived);
664
+ return derived;
665
+ }
666
+
667
+ /** Compute one file's watch-input list over the shared per-envelope state. */
668
+ function deriveWatchInputs(
669
+ state: TtscEnvelopeDerivation,
670
+ props: {
671
+ file: string;
672
+ projectRoot: string;
673
+ result: ITtscCompilerTransformation;
674
+ temporaryTsconfig?: string;
675
+ },
676
+ fileIdentity: string,
677
+ ): string[] {
678
+ const graph = envelopeGraphIndexes(state, props);
436
679
  const output: string[] = [];
437
680
  const seen = new Set<string>();
438
- const excluded = new Set(
439
- props.temporaryTsconfig === undefined
440
- ? [pathIdentityKey(props.file)]
441
- : [pathIdentityKey(props.file), pathIdentityKey(props.temporaryTsconfig)],
442
- );
681
+ const excluded = new Set([fileIdentity]);
682
+ if (props.temporaryTsconfig !== undefined) {
683
+ excluded.add(derivationIdentity(state, props.temporaryTsconfig));
684
+ }
443
685
  for (const absolute of [
444
686
  ...selectFileDependencies(props),
445
- ...selectGraphInputs({
687
+ ...selectGraphInputs(graph, state, {
446
688
  ...props,
447
- complete: declaresCompleteDependencies(props) && !isVolatileFile(props),
689
+ complete:
690
+ declaresCompleteDependencies(state, props) &&
691
+ !isVolatileFile(state, props),
448
692
  }),
449
- ...selectResolutionCandidateInputs(props),
693
+ ...selectResolutionCandidateInputs(graph, state, props),
450
694
  ]) {
451
- const identity = pathIdentityKey(absolute);
695
+ const identity = derivationIdentity(state, absolute);
452
696
  if (excluded.has(identity) || seen.has(identity)) {
453
697
  continue;
454
698
  }
@@ -463,35 +707,37 @@ function selectWatchInputs(props: {
463
707
  * module reachable from `file`. They remain host-owned even when a plugin
464
708
  * declares `dependenciesComplete`: plugin code cannot vouch for a compiler
465
709
  * resolution change that occurs without any plugin input changing.
710
+ *
711
+ * Candidate entries and their source identities come from the shared
712
+ * per-envelope state, so one delivery scans only the candidates themselves
713
+ * instead of re-resolving every candidate source.
466
714
  */
467
- function selectResolutionCandidateInputs(props: {
468
- file: string;
469
- projectRoot: string;
470
- result: ITtscCompilerTransformation;
471
- }): string[] {
472
- if (props.result.type === "exception") {
473
- return [];
474
- }
475
- const graph = props.result.graph;
476
- if (graph === undefined || graph.candidates === undefined) {
715
+ function selectResolutionCandidateInputs(
716
+ graph: TtscEnvelopeGraphIndexes,
717
+ state: TtscEnvelopeDerivation,
718
+ props: {
719
+ file: string;
720
+ projectRoot: string;
721
+ result: ITtscCompilerTransformation;
722
+ },
723
+ ): string[] {
724
+ if (
725
+ props.result.type === "exception" ||
726
+ props.result.graph?.candidates === undefined
727
+ ) {
477
728
  return [];
478
729
  }
479
730
  const reachable = new Set(
480
- selectReachableSources(props.projectRoot, props.file, graph).map(
481
- pathIdentityKey,
731
+ selectReachableSources(graph, state, props.file).map((source) =>
732
+ derivationIdentity(state, source),
482
733
  ),
483
734
  );
484
735
  const output: string[] = [];
485
- for (const [source, candidates] of Object.entries(graph.candidates)) {
486
- if (
487
- !reachable.has(
488
- pathIdentityKey(path.resolve(props.projectRoot, source)),
489
- ) ||
490
- !Array.isArray(candidates)
491
- ) {
736
+ for (const entry of graph.candidates) {
737
+ if (!reachable.has(entry.source)) {
492
738
  continue;
493
739
  }
494
- output.push(...selectListedFiles(props.projectRoot, candidates));
740
+ output.push(...entry.files);
495
741
  }
496
742
  return output;
497
743
  }
@@ -510,62 +756,47 @@ function selectResolutionCandidateInputs(props: {
510
756
  * `dependencies[file]` list the complete replacement for them. Returns an empty
511
757
  * list on exceptions or without a graph.
512
758
  */
513
- function selectGraphInputs(props: {
514
- complete: boolean;
515
- file: string;
516
- projectRoot: string;
517
- result: ITtscCompilerTransformation;
518
- }): string[] {
519
- if (props.result.type === "exception") {
520
- return [];
521
- }
522
- const graph = props.result.graph;
523
- if (graph === undefined) {
759
+ function selectGraphInputs(
760
+ graph: TtscEnvelopeGraphIndexes,
761
+ state: TtscEnvelopeDerivation,
762
+ props: {
763
+ complete: boolean;
764
+ file: string;
765
+ projectRoot: string;
766
+ result: ITtscCompilerTransformation;
767
+ },
768
+ ): string[] {
769
+ if (props.result.type === "exception" || props.result.graph === undefined) {
524
770
  return [];
525
771
  }
526
772
  const output: string[] = [];
527
773
  if (!props.complete) {
528
- output.push(...selectReachableEdges(props.projectRoot, props.file, graph));
529
- output.push(...selectListedFiles(props.projectRoot, graph.globals));
774
+ output.push(...selectReachableEdges(graph, state, props.file));
775
+ output.push(...graph.globals);
530
776
  }
531
- output.push(...selectListedFiles(props.projectRoot, graph.configs));
777
+ output.push(...graph.configs);
532
778
  return output;
533
779
  }
534
780
 
535
781
  /**
536
782
  * Walk the reachability closure of the graph's direct `edges` from `file`,
537
783
  * returning the absolute path of every file reached (the starting file itself
538
- * excluded, even when a cycle points back at it).
784
+ * excluded, even when a cycle points back at it). Reads the shared per-envelope
785
+ * edge index instead of rebuilding it per delivery.
539
786
  */
540
787
  function selectReachableEdges(
541
- projectRoot: string,
788
+ graph: TtscEnvelopeGraphIndexes,
789
+ state: TtscEnvelopeDerivation,
542
790
  file: string,
543
- graph: ITtscCompilerTransformation.IReferenceGraph,
544
791
  ): string[] {
545
- const edges = new Map<string, string[]>();
546
- for (const [source, targets] of Object.entries(graph.edges ?? {})) {
547
- if (!Array.isArray(targets)) {
548
- continue;
549
- }
550
- const identity = pathIdentityKey(path.resolve(projectRoot, source));
551
- const entries = edges.get(identity) ?? [];
552
- entries.push(
553
- ...targets
554
- .filter(
555
- (target): target is string =>
556
- typeof target === "string" && target.length !== 0,
557
- )
558
- .map((target) => path.resolve(projectRoot, target)),
559
- );
560
- edges.set(identity, entries);
561
- }
562
792
  const output: string[] = [];
563
- const visited = new Set<string>([pathIdentityKey(file)]);
793
+ const visited = new Set<string>([derivationIdentity(state, file)]);
564
794
  const queue = [file];
565
795
  while (queue.length !== 0) {
566
796
  const current = queue.pop()!;
567
- for (const target of edges.get(pathIdentityKey(current)) ?? []) {
568
- const identity = pathIdentityKey(target);
797
+ for (const target of graph.edges.get(derivationIdentity(state, current)) ??
798
+ []) {
799
+ const identity = derivationIdentity(state, target);
569
800
  if (visited.has(identity)) {
570
801
  continue;
571
802
  }
@@ -583,43 +814,24 @@ function selectReachableEdges(
583
814
  * than targets, so this is intentionally distinct from selectReachableEdges.
584
815
  */
585
816
  function selectReachableSources(
586
- projectRoot: string,
817
+ graph: TtscEnvelopeGraphIndexes,
818
+ state: TtscEnvelopeDerivation,
587
819
  file: string,
588
- graph: ITtscCompilerTransformation.IReferenceGraph,
589
820
  ): string[] {
590
- const edges = new Map<string, string[]>();
591
- const spellings = new Map<string, string>();
592
- for (const [source, targets] of Object.entries(graph.edges ?? {})) {
593
- if (!Array.isArray(targets)) {
594
- continue;
595
- }
596
- const absolute = path.resolve(projectRoot, source);
597
- const identity = pathIdentityKey(absolute);
598
- spellings.set(identity, absolute);
599
- const entries = edges.get(identity) ?? [];
600
- entries.push(
601
- ...targets
602
- .filter(
603
- (target): target is string =>
604
- typeof target === "string" && target.length !== 0,
605
- )
606
- .map((target) => path.resolve(projectRoot, target)),
607
- );
608
- edges.set(identity, entries);
609
- }
610
821
  const output = [file];
611
- const visited = new Set<string>([pathIdentityKey(file)]);
822
+ const visited = new Set<string>([derivationIdentity(state, file)]);
612
823
  const queue = [file];
613
824
  while (queue.length !== 0) {
614
825
  const current = queue.pop()!;
615
- for (const target of edges.get(pathIdentityKey(current)) ?? []) {
616
- const identity = pathIdentityKey(target);
826
+ for (const target of graph.edges.get(derivationIdentity(state, current)) ??
827
+ []) {
828
+ const identity = derivationIdentity(state, target);
617
829
  if (visited.has(identity)) {
618
830
  continue;
619
831
  }
620
832
  visited.add(identity);
621
833
  queue.push(target);
622
- output.push(spellings.get(identity) ?? target);
834
+ output.push(graph.spellings.get(identity) ?? target);
623
835
  }
624
836
  }
625
837
  return output;
@@ -647,17 +859,26 @@ function selectListedFiles(projectRoot: string, listed: unknown): string[] {
647
859
  /**
648
860
  * Report whether the plugin declared `file` volatile: its output depends on
649
861
  * non-file inputs (environment, time, network), so neither the project
650
- * transform cache nor a bundler's persistent cache may replay it.
862
+ * transform cache nor a bundler's persistent cache may replay it. Reads the
863
+ * per-envelope identity set instead of rescanning the member list.
651
864
  */
652
- function isVolatileFile(props: {
653
- file: string;
654
- projectRoot: string;
655
- result: ITtscCompilerTransformation;
656
- }): boolean {
865
+ function isVolatileFile(
866
+ state: TtscEnvelopeDerivation,
867
+ props: {
868
+ file: string;
869
+ projectRoot: string;
870
+ result: ITtscCompilerTransformation;
871
+ },
872
+ ): boolean {
657
873
  if (props.result.type === "exception") {
658
874
  return false;
659
875
  }
660
- return declaresFile(props.result.volatile, props);
876
+ const declared = (state.volatileFiles ??= collectDeclaredIdentities(
877
+ state,
878
+ props.projectRoot,
879
+ props.result.volatile,
880
+ ));
881
+ return declared.has(derivationIdentity(state, props.file));
661
882
  }
662
883
 
663
884
  /**
@@ -666,44 +887,30 @@ function isVolatileFile(props: {
666
887
  * itself and the universal config chain. Callers must still keep the baseline
667
888
  * for a file the same envelope declared volatile.
668
889
  */
669
- function declaresCompleteDependencies(props: {
670
- file: string;
671
- projectRoot: string;
672
- result: ITtscCompilerTransformation;
673
- }): boolean {
674
- if (props.result.type === "exception") {
675
- return false;
676
- }
677
- return declaresFile(props.result.dependenciesComplete, props);
678
- }
679
-
680
- /**
681
- * Report whether one of the envelope's transformed-file lists (`volatile`,
682
- * `dependenciesComplete`) names `file`. Members are keyed like `typescript`, so
683
- * a project-relative and an absolute spelling of the same file both match; a
684
- * malformed member is ignored rather than fatal.
685
- */
686
- function declaresFile(
687
- listed: unknown,
688
- props: { file: string; projectRoot: string },
890
+ function declaresCompleteDependencies(
891
+ state: TtscEnvelopeDerivation,
892
+ props: {
893
+ file: string;
894
+ projectRoot: string;
895
+ result: ITtscCompilerTransformation;
896
+ },
689
897
  ): boolean {
690
- if (!Array.isArray(listed)) {
898
+ if (props.result.type === "exception") {
691
899
  return false;
692
900
  }
693
- return listed.some(
694
- (entry) =>
695
- typeof entry === "string" &&
696
- entry.length !== 0 &&
697
- pathIdentityKey(path.resolve(props.projectRoot, entry)) ===
698
- pathIdentityKey(props.file),
699
- );
901
+ const declared = (state.dependenciesComplete ??= collectDeclaredIdentities(
902
+ state,
903
+ props.projectRoot,
904
+ props.result.dependenciesComplete,
905
+ ));
906
+ return declared.has(derivationIdentity(state, props.file));
700
907
  }
701
908
 
702
909
  /**
703
910
  * Extract the absolute, deduplicated dependency list for a single file from the
704
911
  * compiler result. Mirrors {@link selectTransformedSource}'s key lookup: fast
705
- * project-relative match first, then a resolve-based scan. Returns an empty
706
- * list on exceptions or when the plugin reported nothing.
912
+ * project-relative match first, then a per-envelope identity index. Returns an
913
+ * empty list on exceptions or when the plugin reported nothing.
707
914
  */
708
915
  function selectFileDependencies(props: {
709
916
  file: string;
@@ -717,31 +924,36 @@ function selectFileDependencies(props: {
717
924
  if (dependencies === undefined) {
718
925
  return [];
719
926
  }
720
- const key = toProjectKey(props.projectRoot, props.file);
927
+ const state = envelopeDerivation(props);
928
+ const key = toProjectKey(
929
+ props.projectRoot,
930
+ props.file,
931
+ state.identityContext,
932
+ );
721
933
  let entries = dependencies[key];
722
934
  if (entries === undefined) {
723
- for (const [candidate, candidateEntries] of Object.entries(dependencies)) {
724
- if (
725
- pathIdentityKey(path.resolve(props.projectRoot, candidate)) ===
726
- pathIdentityKey(props.file)
727
- ) {
728
- entries = candidateEntries;
729
- break;
730
- }
731
- }
935
+ const index = (state.dependencyIndex ??= createEnvelopeKeyIndex(
936
+ state,
937
+ props.projectRoot,
938
+ dependencies,
939
+ ));
940
+ entries = index.get(derivationIdentity(state, props.file)) as
941
+ | string[]
942
+ | undefined;
732
943
  }
733
944
  if (!Array.isArray(entries)) {
734
945
  return [];
735
946
  }
736
947
  const output: string[] = [];
737
948
  const seen = new Set<string>();
949
+ const fileIdentity = derivationIdentity(state, props.file);
738
950
  for (const entry of entries) {
739
951
  if (typeof entry !== "string" || entry.length === 0) {
740
952
  continue;
741
953
  }
742
954
  const absolute = path.resolve(props.projectRoot, entry);
743
- const identity = pathIdentityKey(absolute);
744
- if (identity === pathIdentityKey(props.file) || seen.has(identity)) {
955
+ const identity = derivationIdentity(state, absolute);
956
+ if (identity === fileIdentity || seen.has(identity)) {
745
957
  continue;
746
958
  }
747
959
  seen.add(identity);
@@ -750,6 +962,29 @@ function selectFileDependencies(props: {
750
962
  return output;
751
963
  }
752
964
 
965
+ /**
966
+ * Build a first-match identity index over one envelope key map (`typescript`,
967
+ * `dependencies`), mirroring the historical per-delivery scan that returned the
968
+ * first entry whose resolved key matched by filesystem identity.
969
+ */
970
+ function createEnvelopeKeyIndex<T>(
971
+ state: TtscEnvelopeDerivation,
972
+ projectRoot: string,
973
+ keyed: Record<string, T>,
974
+ ): Map<string, T> {
975
+ const index = new Map<string, T>();
976
+ for (const [candidate, value] of Object.entries(keyed)) {
977
+ const identity = derivationIdentity(
978
+ state,
979
+ path.resolve(projectRoot, candidate),
980
+ );
981
+ if (!index.has(identity)) {
982
+ index.set(identity, value);
983
+ }
984
+ }
985
+ return index;
986
+ }
987
+
753
988
  /**
754
989
  * Strip a query string or hash fragment from a bundler module id.
755
990
  *
@@ -819,14 +1054,21 @@ function matchesCachedSource(
819
1054
  source: string,
820
1055
  buildScoped: boolean,
821
1056
  ): boolean {
822
- const currentKey = toProjectKey(cached.projectRoot, file);
1057
+ const identities = envelopeDerivation(cached).identityContext;
1058
+ const currentKey = toProjectKey(cached.projectRoot, file, identities);
823
1059
  if (cached.inputHashes[currentKey] !== hashText(source)) {
824
1060
  return false;
825
1061
  }
826
- if (buildScoped && !cached.servedFiles?.has(pathIdentityKey(file))) {
1062
+ if (
1063
+ buildScoped &&
1064
+ !cached.servedFiles?.has(pathIdentityKey(file, identities))
1065
+ ) {
827
1066
  return true;
828
1067
  }
829
- const currentHashes = collectProjectInputHashes(cached.projectRoot);
1068
+ const currentHashes = collectProjectInputHashes(
1069
+ cached.projectRoot,
1070
+ identities,
1071
+ );
830
1072
  currentHashes[currentKey] = hashText(source);
831
1073
  if (!sameHashes(cached.inputHashes, currentHashes)) {
832
1074
  return false;
@@ -853,7 +1095,9 @@ function markCachedSourceServed(
853
1095
  cached: TtscCachedProjectTransform,
854
1096
  file: string,
855
1097
  ): void {
856
- (cached.servedFiles ??= new Set()).add(pathIdentityKey(file));
1098
+ (cached.servedFiles ??= new Set()).add(
1099
+ pathIdentityKey(file, envelopeDerivation(cached).identityContext),
1100
+ );
857
1101
  }
858
1102
 
859
1103
  /**
@@ -874,11 +1118,11 @@ function collectInputHashes(props: {
874
1118
  currentSource: string;
875
1119
  projectRoot: string;
876
1120
  }): Record<string, string> {
877
- const hashes = collectProjectInputHashes(props.projectRoot);
1121
+ const identities = createHostPathIdentityContext();
1122
+ const hashes = collectProjectInputHashes(props.projectRoot, identities);
878
1123
  // Overlay the in-memory source so unsaved edits invalidate the cache.
879
- hashes[toProjectKey(props.projectRoot, props.currentFile)] = hashText(
880
- props.currentSource,
881
- );
1124
+ hashes[toProjectKey(props.projectRoot, props.currentFile, identities)] =
1125
+ hashText(props.currentSource);
882
1126
  return hashes;
883
1127
  }
884
1128
 
@@ -890,11 +1134,14 @@ function collectInputHashes(props: {
890
1134
  */
891
1135
  export function collectProjectInputHashes(
892
1136
  projectRoot: string,
1137
+ identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
893
1138
  ): Record<string, string> {
894
1139
  const hashes: Record<string, string> = {};
895
1140
  for (const file of listProjectInputFiles(projectRoot)) {
896
1141
  try {
897
- hashes[toProjectKey(projectRoot, file)] = hashText(fs.readFileSync(file));
1142
+ hashes[toProjectKey(projectRoot, file, identities)] = hashText(
1143
+ fs.readFileSync(file),
1144
+ );
898
1145
  } catch {
899
1146
  // File watchers may observe a transform while another process is moving
900
1147
  // or deleting files. The missing key invalidates older cache entries.
@@ -947,8 +1194,17 @@ function listProjectInputFiles(root: string): string[] {
947
1194
  * Missing paths and files reached through symlinks or Windows junctions are
948
1195
  * out-of-walk inputs that only the reference graph can prove relevant.
949
1196
  */
950
- export function isProjectWalkPath(root: string, file: string): boolean {
951
- const relative = path.relative(pathIdentityKey(root), pathIdentityKey(file));
1197
+ export function isProjectWalkPath(
1198
+ root: string,
1199
+ file: string,
1200
+ identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
1201
+ ): boolean {
1202
+ if (!identities.isWithin(root, file)) {
1203
+ return false;
1204
+ }
1205
+ const rootKey = pathIdentityKey(root, identities);
1206
+ const fileKey = pathIdentityKey(file, identities);
1207
+ const relative = fileKey.slice(rootKey.length).replace(/^[/\\]+/, "");
952
1208
  if (
953
1209
  relative.length === 0 ||
954
1210
  relative === ".." ||
@@ -994,8 +1250,9 @@ export function collectExternalInputHashes(
994
1250
  paths: readonly string[],
995
1251
  ): Record<string, string> {
996
1252
  const hashes: Record<string, string> = {};
1253
+ const identities = createHostPathIdentityContext();
997
1254
  for (const file of paths) {
998
- const identity = pathIdentityKey(file);
1255
+ const identity = pathIdentityKey(file, identities);
999
1256
  if (identity in hashes) {
1000
1257
  continue;
1001
1258
  }
@@ -1035,6 +1292,7 @@ function selectExternalInputPaths(props: {
1035
1292
  return [];
1036
1293
  }
1037
1294
  const members: string[] = [];
1295
+ const identities = createHostPathIdentityContext();
1038
1296
  const resolutionCandidates = new Set<string>();
1039
1297
  const graph = props.result.graph;
1040
1298
  if (graph !== undefined) {
@@ -1059,7 +1317,7 @@ function selectExternalInputPaths(props: {
1059
1317
  }
1060
1318
  const absolute = path.resolve(props.projectRoot, candidate);
1061
1319
  members.push(candidate);
1062
- resolutionCandidates.add(pathIdentityKey(absolute));
1320
+ resolutionCandidates.add(pathIdentityKey(absolute, identities));
1063
1321
  }
1064
1322
  }
1065
1323
  }
@@ -1071,7 +1329,7 @@ function selectExternalInputPaths(props: {
1071
1329
  const excluded =
1072
1330
  props.temporaryTsconfig === undefined
1073
1331
  ? undefined
1074
- : pathIdentityKey(props.temporaryTsconfig);
1332
+ : pathIdentityKey(props.temporaryTsconfig, identities);
1075
1333
  const output: string[] = [];
1076
1334
  const seen = new Set<string>();
1077
1335
  for (const member of members) {
@@ -1079,13 +1337,14 @@ function selectExternalInputPaths(props: {
1079
1337
  continue;
1080
1338
  }
1081
1339
  const absolute = path.resolve(props.projectRoot, member);
1082
- const identity = pathIdentityKey(absolute);
1340
+ const identity = pathIdentityKey(absolute, identities);
1083
1341
  const missingCandidate =
1084
1342
  resolutionCandidates.has(identity) && !fs.existsSync(absolute);
1085
1343
  if (
1086
1344
  identity === excluded ||
1087
1345
  seen.has(identity) ||
1088
- (!missingCandidate && isProjectWalkPath(props.projectRoot, absolute))
1346
+ (!missingCandidate &&
1347
+ isProjectWalkPath(props.projectRoot, absolute, identities))
1089
1348
  ) {
1090
1349
  continue;
1091
1350
  }
@@ -1474,19 +1733,26 @@ function selectTransformedSource(props: {
1474
1733
  }
1475
1734
 
1476
1735
  // Fast path: the compiler key matches the normalised project-relative path.
1477
- const key = toProjectKey(props.projectRoot, props.file);
1736
+ const state = envelopeDerivation(props);
1737
+ const key = toProjectKey(
1738
+ props.projectRoot,
1739
+ props.file,
1740
+ state.identityContext,
1741
+ );
1478
1742
  const direct = props.result.typescript[key];
1479
1743
  if (direct !== undefined) {
1480
1744
  return direct;
1481
1745
  }
1482
- // Slow path: resolve each candidate to an absolute path for comparison.
1483
- for (const [candidate, source] of Object.entries(props.result.typescript)) {
1484
- if (
1485
- pathIdentityKey(path.resolve(props.projectRoot, candidate)) ===
1486
- pathIdentityKey(props.file)
1487
- ) {
1488
- return source;
1489
- }
1746
+ // Slow path: the first-match identity index of the envelope's `typescript`
1747
+ // keys, built once per generation instead of scanned per delivery.
1748
+ const index = (state.outputIndex ??= createEnvelopeKeyIndex(
1749
+ state,
1750
+ props.projectRoot,
1751
+ props.result.typescript,
1752
+ ));
1753
+ const source = index.get(derivationIdentity(state, props.file));
1754
+ if (source !== undefined) {
1755
+ return source;
1490
1756
  }
1491
1757
  throw new Error(`ttsc transform did not return output for ${props.file}`);
1492
1758
  }
@@ -1583,10 +1849,17 @@ function resolveTsconfig(file: string, tsconfig?: string): string {
1583
1849
  return path.resolve(process.cwd(), "tsconfig.json");
1584
1850
  }
1585
1851
 
1586
- function toProjectKey(root: string, file: string): string {
1587
- return normalizePath(
1588
- path.relative(pathIdentityKey(root), pathIdentityKey(file)),
1589
- );
1852
+ function toProjectKey(
1853
+ root: string,
1854
+ file: string,
1855
+ identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
1856
+ ): string {
1857
+ const rootKey = pathIdentityKey(root, identities);
1858
+ const fileKey = pathIdentityKey(file, identities);
1859
+ if (!identities.isWithin(root, file)) {
1860
+ return normalizePath(fileKey);
1861
+ }
1862
+ return normalizePath(fileKey.slice(rootKey.length).replace(/^[/\\]+/, ""));
1590
1863
  }
1591
1864
 
1592
1865
  /**
@@ -1594,55 +1867,11 @@ function toProjectKey(root: string, file: string): string {
1594
1867
  * filesystem or bundler. Windows is case-insensitive; macOS is probed per
1595
1868
  * existing filesystem location so case-sensitive volumes keep distinct paths.
1596
1869
  */
1597
- export function pathIdentityKey(file: string): string {
1598
- const absolute = path.resolve(file);
1599
- return filesystemIsCaseInsensitive(absolute)
1600
- ? absolute.toLowerCase()
1601
- : absolute;
1602
- }
1603
-
1604
- function filesystemIsCaseInsensitive(file: string): boolean {
1605
- if (process.platform === "win32") {
1606
- return true;
1607
- }
1608
- if (process.platform !== "darwin") {
1609
- return false;
1610
- }
1611
- let existing = file;
1612
- while (!fs.existsSync(existing)) {
1613
- const parent = path.dirname(existing);
1614
- if (parent === existing) {
1615
- return false;
1616
- }
1617
- existing = parent;
1618
- }
1619
- let resolved: string;
1620
- try {
1621
- resolved = fs.realpathSync.native(existing);
1622
- } catch {
1623
- return false;
1624
- }
1625
- const cached = CASE_INSENSITIVE_FILESYSTEMS.get(resolved);
1626
- if (cached !== undefined) {
1627
- return cached;
1628
- }
1629
- const alternate = togglePathCase(resolved);
1630
- const insensitive = alternate !== undefined && fs.existsSync(alternate);
1631
- CASE_INSENSITIVE_FILESYSTEMS.set(resolved, insensitive);
1632
- return insensitive;
1633
- }
1634
-
1635
- function togglePathCase(file: string): string | undefined {
1636
- for (let index = file.length - 1; index >= 0; --index) {
1637
- const character = file[index]!;
1638
- if (character >= "a" && character <= "z") {
1639
- return `${file.slice(0, index)}${character.toUpperCase()}${file.slice(index + 1)}`;
1640
- }
1641
- if (character >= "A" && character <= "Z") {
1642
- return `${file.slice(0, index)}${character.toLowerCase()}${file.slice(index + 1)}`;
1643
- }
1644
- }
1645
- return undefined;
1870
+ export function pathIdentityKey(
1871
+ file: string,
1872
+ identities: FilesystemPathIdentityContext = createHostPathIdentityContext(),
1873
+ ): string {
1874
+ return identities.resolve(file).key;
1646
1875
  }
1647
1876
 
1648
1877
  function normalizePath(file: string): string {