@ttsc/unplugin 0.28.5 → 0.29.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.
Files changed (86) hide show
  1. package/README.md +15 -11
  2. package/lib/api.js +10 -0
  3. package/lib/api.js.map +1 -1
  4. package/lib/api.mjs +3 -2
  5. package/lib/api.mjs.map +1 -1
  6. package/lib/bun-register.d.cts +10 -7
  7. package/lib/bun-register.d.mts +10 -7
  8. package/lib/bun-register.d.ts +10 -7
  9. package/lib/bun-register.js +90 -24
  10. package/lib/bun-register.js.map +1 -1
  11. package/lib/bun-register.mjs +90 -24
  12. package/lib/bun-register.mjs.map +1 -1
  13. package/lib/bun.d.cts +10 -8
  14. package/lib/bun.d.mts +10 -8
  15. package/lib/bun.d.ts +10 -8
  16. package/lib/bun.js +19 -17
  17. package/lib/bun.js.map +1 -1
  18. package/lib/bun.mjs +20 -18
  19. package/lib/bun.mjs.map +1 -1
  20. package/lib/core/index.d.cts +10 -8
  21. package/lib/core/index.d.mts +10 -8
  22. package/lib/core/index.d.ts +10 -8
  23. package/lib/core/index.js +88 -16
  24. package/lib/core/index.js.map +1 -1
  25. package/lib/core/index.mjs +81 -18
  26. package/lib/core/index.mjs.map +1 -1
  27. package/lib/core/projectDiscovery.d.cts +55 -0
  28. package/lib/core/projectDiscovery.d.mts +55 -0
  29. package/lib/core/projectDiscovery.d.ts +55 -0
  30. package/lib/core/projectDiscovery.js +187 -0
  31. package/lib/core/projectDiscovery.js.map +1 -0
  32. package/lib/core/projectDiscovery.mjs +182 -0
  33. package/lib/core/projectDiscovery.mjs.map +1 -0
  34. package/lib/core/sourceExtensions.d.cts +10 -0
  35. package/lib/core/sourceExtensions.d.mts +10 -0
  36. package/lib/core/sourceExtensions.d.ts +10 -0
  37. package/lib/core/sourceExtensions.js +38 -0
  38. package/lib/core/sourceExtensions.js.map +1 -0
  39. package/lib/core/sourceExtensions.mjs +32 -0
  40. package/lib/core/sourceExtensions.mjs.map +1 -0
  41. package/lib/core/transform.d.cts +96 -16
  42. package/lib/core/transform.d.mts +96 -16
  43. package/lib/core/transform.d.ts +96 -16
  44. package/lib/core/transform.js +1295 -309
  45. package/lib/core/transform.js.map +1 -1
  46. package/lib/core/transform.mjs +1290 -310
  47. package/lib/core/transform.mjs.map +1 -1
  48. package/lib/core/tsconfigPaths.d.cts +55 -3
  49. package/lib/core/tsconfigPaths.d.mts +55 -3
  50. package/lib/core/tsconfigPaths.d.ts +55 -3
  51. package/lib/core/tsconfigPaths.js +260 -46
  52. package/lib/core/tsconfigPaths.js.map +1 -1
  53. package/lib/core/tsconfigPaths.mjs +255 -47
  54. package/lib/core/tsconfigPaths.mjs.map +1 -1
  55. package/lib/core/viteServe.d.cts +18 -21
  56. package/lib/core/viteServe.d.mts +18 -21
  57. package/lib/core/viteServe.d.ts +18 -21
  58. package/lib/core/viteServe.js +88 -47
  59. package/lib/core/viteServe.js.map +1 -1
  60. package/lib/core/viteServe.mjs +89 -49
  61. package/lib/core/viteServe.mjs.map +1 -1
  62. package/lib/next.d.cts +7 -0
  63. package/lib/next.d.mts +7 -0
  64. package/lib/next.d.ts +7 -0
  65. package/lib/next.js +186 -54
  66. package/lib/next.js.map +1 -1
  67. package/lib/next.mjs +186 -55
  68. package/lib/next.mjs.map +1 -1
  69. package/lib/turbopack.d.cts +6 -4
  70. package/lib/turbopack.d.mts +6 -4
  71. package/lib/turbopack.d.ts +6 -4
  72. package/lib/turbopack.js +11 -10
  73. package/lib/turbopack.js.map +1 -1
  74. package/lib/turbopack.mjs +11 -10
  75. package/lib/turbopack.mjs.map +1 -1
  76. package/package.json +7 -4
  77. package/src/bun-register.ts +112 -25
  78. package/src/bun.ts +60 -50
  79. package/src/core/index.ts +114 -19
  80. package/src/core/projectDiscovery.ts +256 -0
  81. package/src/core/sourceExtensions.ts +47 -0
  82. package/src/core/transform.ts +1671 -361
  83. package/src/core/tsconfigPaths.ts +406 -46
  84. package/src/core/viteServe.ts +125 -74
  85. package/src/next.ts +230 -54
  86. package/src/turbopack.ts +11 -10
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
 
5
+ import { TYPESCRIPT_TRANSFORM_EXTENSIONS } from "./sourceExtensions";
6
+
5
7
  /**
6
8
  * Read the effective `compilerOptions.paths` of a tsconfig, following its
7
9
  * `extends` chain, and absolutize every mapping target.
@@ -27,7 +29,8 @@ import path from "node:path";
27
29
  export function readEffectiveTsconfigPaths(
28
30
  tsconfig: string,
29
31
  ): Record<string, string[]> {
30
- const declared = findDeclaredPaths(path.resolve(tsconfig), new Set());
32
+ const resolved = path.resolve(tsconfig);
33
+ const declared = findDeclaredPaths(resolved, new Set());
31
34
  if (declared === null) {
32
35
  return {};
33
36
  }
@@ -38,7 +41,9 @@ export function readEffectiveTsconfigPaths(
38
41
  }
39
42
  const absolute = targets
40
43
  .filter((target): target is string => typeof target === "string")
41
- .map((target) => absolutizePathsTarget(declared.baseDir, target));
44
+ .map((target) =>
45
+ absolutizePathsTarget(declared.baseDir, target, path.dirname(resolved)),
46
+ );
42
47
  if (absolute.length !== 0) {
43
48
  output[key] = absolute;
44
49
  }
@@ -46,11 +51,59 @@ export function readEffectiveTsconfigPaths(
46
51
  return output;
47
52
  }
48
53
 
49
- /** Source extensions TypeScript always admits as program inputs. */
50
- const TYPESCRIPT_INPUT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
51
54
  /** Extensions admitted only when `allowJs` widens the program. */
52
55
  const JAVASCRIPT_INPUT_EXTENSIONS = [".js", ".jsx", ".mjs", ".cjs"];
53
56
 
57
+ /** Compiler options that exclude exactly one resolved output directory. */
58
+ const OUTPUT_DIRECTORY_OPTIONS = ["outDir", "declarationDir"] as const;
59
+
60
+ /** Scalar compiler paths TypeScript substitutes from the final config owner. */
61
+ export const CONFIG_DIR_TEMPLATE_SCALAR_OPTIONS = [
62
+ "baseUrl",
63
+ "declarationDir",
64
+ "generateCpuProfile",
65
+ "generateTrace",
66
+ "outDir",
67
+ "outFile",
68
+ "rootDir",
69
+ "tsBuildInfoFile",
70
+ ] as const;
71
+
72
+ /** List compiler paths TypeScript substitutes from the final config owner. */
73
+ export const CONFIG_DIR_TEMPLATE_LIST_OPTIONS = [
74
+ "rootDirs",
75
+ "typeRoots",
76
+ ] as const;
77
+
78
+ /** Top-level file specifications that accept the same template. */
79
+ const CONFIG_DIR_TEMPLATE_FILE_SPECS = ["exclude", "files", "include"] as const;
80
+
81
+ /** One config-chain input captured for generation-state comparison. */
82
+ export interface ITsconfigSourceSnapshotEntry {
83
+ /** Exact bytes decoded as UTF-8, or `null` when a probed config is absent. */
84
+ contents: string | null;
85
+ /** Canonical absolute spelling when the input exists. */
86
+ path: string;
87
+ }
88
+
89
+ /**
90
+ * Capture the leaf config and its complete `extends` graph in one deterministic
91
+ * list.
92
+ *
93
+ * Transform wrappers derive configuration before the compiler reads it again.
94
+ * Comparing this snapshot across the compile prevents a wrapper derived from
95
+ * one config state from being paired with membership and output from another.
96
+ */
97
+ export function readTsconfigSourceSnapshot(
98
+ tsconfig: string,
99
+ ): ITsconfigSourceSnapshotEntry[] {
100
+ const output = new Map<string, string | null>();
101
+ collectTsconfigSourceSnapshot(path.resolve(tsconfig), new Set(), output);
102
+ return [...output.entries()]
103
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
104
+ .map(([source, contents]) => ({ contents, path: source }));
105
+ }
106
+
54
107
  /**
55
108
  * What the resolved configuration says can and cannot enter the program.
56
109
  *
@@ -63,6 +116,22 @@ const JAVASCRIPT_INPUT_EXTENSIONS = [".js", ".jsx", ".mjs", ".cjs"];
63
116
  * dropped from the walk entirely (samchon/ttsc#1307).
64
117
  */
65
118
  export interface ITtscProjectMembershipPolicy {
119
+ /**
120
+ * Absolute directory exclusions separated by the configuration entry that
121
+ * contributed them.
122
+ *
123
+ * Optional for compatibility with hosts that constructed this public policy
124
+ * before exclusion provenance was exposed. Without it, an overlay preserves
125
+ * every entry in `excludedDirectories` as an explicit exclusion because
126
+ * silently admitting a directory is the unsafe fallback.
127
+ */
128
+ directoryExclusionOrigins?: Readonly<{
129
+ declarationDir?: string;
130
+ exclude: readonly string[];
131
+ outDir?: string;
132
+ /** Whether output options supply TypeScript's implicit default exclude. */
133
+ useImplicitOutputExclusions?: boolean;
134
+ }>;
66
135
  /** Absolute directories the resolved configuration keeps out of the program. */
67
136
  excludedDirectories: readonly string[];
68
137
  /** Lowercased extensions a file needs to be a possible program input. */
@@ -79,6 +148,23 @@ export interface ITtscProjectMembershipPolicy {
79
148
  sources: readonly string[];
80
149
  }
81
150
 
151
+ /** Flatten provenance into the directory list consumed by the project walk. */
152
+ function flattenDirectoryExclusionOrigins(
153
+ origins: NonNullable<
154
+ ITtscProjectMembershipPolicy["directoryExclusionOrigins"]
155
+ >,
156
+ ): string[] {
157
+ return [
158
+ ...(origins.useImplicitOutputExclusions === false
159
+ ? []
160
+ : OUTPUT_DIRECTORY_OPTIONS.flatMap((key) => {
161
+ const directory = origins[key];
162
+ return directory === undefined ? [] : [directory];
163
+ })),
164
+ ...origins.exclude,
165
+ ];
166
+ }
167
+
82
168
  /**
83
169
  * The policy every project falls back to when no configuration is available.
84
170
  *
@@ -88,9 +174,13 @@ export interface ITtscProjectMembershipPolicy {
88
174
  */
89
175
  export const PERMISSIVE_PROJECT_MEMBERSHIP_POLICY: ITtscProjectMembershipPolicy =
90
176
  {
177
+ directoryExclusionOrigins: {
178
+ exclude: [],
179
+ useImplicitOutputExclusions: true,
180
+ },
91
181
  excludedDirectories: [],
92
182
  inputExtensions: [
93
- ...TYPESCRIPT_INPUT_EXTENSIONS,
183
+ ...TYPESCRIPT_TRANSFORM_EXTENSIONS,
94
184
  ...JAVASCRIPT_INPUT_EXTENSIONS,
95
185
  ".json",
96
186
  ],
@@ -105,8 +195,8 @@ export const PERMISSIVE_PROJECT_MEMBERSHIP_POLICY: ITtscProjectMembershipPolicy
105
195
  * program at all, so a `bundle.a1b2c3.js` emitted beside the sources is not a
106
196
  * membership change for a project that admits no JavaScript. `outDir`,
107
197
  * `declarationDir`, and the plain entries of `exclude` name the directories the
108
- * program does not contain, which is where a bundler's own output lives in
109
- * every project that configures one.
198
+ * program does not contain. TypeScript supplies the two output directories as
199
+ * implicit exclusions only when no top-level `exclude` replaces that default.
110
200
  *
111
201
  * A glob in `exclude` is skipped rather than approximated. Failing to exclude
112
202
  * costs a walk; excluding the wrong tree hides real sources, and this function
@@ -132,7 +222,7 @@ export function readProjectMembershipPolicy(
132
222
  sources,
133
223
  )?.value === true;
134
224
 
135
- const inputExtensions = [...TYPESCRIPT_INPUT_EXTENSIONS];
225
+ const inputExtensions = [...TYPESCRIPT_TRANSFORM_EXTENSIONS];
136
226
  if (flag("allowJs")) {
137
227
  inputExtensions.push(...JAVASCRIPT_INPUT_EXTENSIONS);
138
228
  }
@@ -140,48 +230,85 @@ export function readProjectMembershipPolicy(
140
230
  inputExtensions.push(".json");
141
231
  }
142
232
 
143
- const excludedDirectories: string[] = [];
144
- for (const key of ["outDir", "declarationDir"]) {
233
+ const directoryExclusionOrigins: {
234
+ declarationDir?: string;
235
+ exclude: string[];
236
+ outDir?: string;
237
+ useImplicitOutputExclusions: boolean;
238
+ } = { exclude: [], useImplicitOutputExclusions: true };
239
+ for (const key of OUTPUT_DIRECTORY_OPTIONS) {
145
240
  const declared = findDeclaredValue(
146
241
  resolved,
147
242
  (parsed) => {
148
- const value = (parsed as { compilerOptions?: Record<string, unknown> })
149
- .compilerOptions?.[key];
150
- return typeof value === "string" && value.length !== 0
151
- ? value
243
+ const options = (
244
+ parsed as { compilerOptions?: Record<string, unknown> }
245
+ ).compilerOptions;
246
+ if (
247
+ options === undefined ||
248
+ !Object.prototype.hasOwnProperty.call(options, key)
249
+ ) {
250
+ return undefined;
251
+ }
252
+ const value = options[key];
253
+ return typeof value === "string" || value === null
254
+ ? { value }
152
255
  : undefined;
153
256
  },
154
257
  new Set(),
155
258
  sources,
156
259
  );
157
- if (declared !== null) {
158
- excludedDirectories.push(path.resolve(declared.baseDir, declared.value));
260
+ if (declared !== null && declared.value.value !== null) {
261
+ directoryExclusionOrigins[key] = resolveConfigDirTemplatePath(
262
+ declared.baseDir,
263
+ declared.value.value,
264
+ path.dirname(resolved),
265
+ );
159
266
  }
160
267
  }
161
268
  const excluded = findDeclaredValue(
162
269
  resolved,
163
270
  (parsed) => {
164
- const value = (parsed as { exclude?: unknown }).exclude;
165
- return Array.isArray(value) ? value : undefined;
271
+ if (!Object.prototype.hasOwnProperty.call(parsed, "exclude")) {
272
+ return undefined;
273
+ }
274
+ return { value: (parsed as { exclude?: unknown }).exclude };
166
275
  },
167
276
  new Set(),
168
277
  sources,
169
278
  );
170
- if (excluded !== null) {
171
- for (const entry of excluded.value) {
279
+ directoryExclusionOrigins.useImplicitOutputExclusions =
280
+ excluded === null || excluded.value.value === null;
281
+ if (excluded !== null && Array.isArray(excluded.value.value)) {
282
+ for (const entry of excluded.value.value) {
172
283
  if (typeof entry !== "string" || entry.length === 0) {
173
284
  continue;
174
285
  }
175
286
  // `dist/**` names exactly one directory; `**/*.spec.ts` names a set this
176
287
  // walk cannot evaluate without a matcher, so it is left in.
177
- const plain = entry.endsWith("/**") ? entry.slice(0, -3) : entry;
288
+ const normalizedEntry = normalizeTypeScriptPathSeparators(entry);
289
+ const plain = normalizedEntry.endsWith("/**")
290
+ ? normalizedEntry.slice(0, -3)
291
+ : normalizedEntry;
178
292
  if (plain.length === 0 || /[*?]/.test(plain)) {
179
293
  continue;
180
294
  }
181
- excludedDirectories.push(path.resolve(excluded.baseDir, plain));
295
+ directoryExclusionOrigins.exclude.push(
296
+ resolveConfigDirTemplatePath(
297
+ excluded.baseDir,
298
+ plain,
299
+ path.dirname(resolved),
300
+ ),
301
+ );
182
302
  }
183
303
  }
184
- return { excludedDirectories, inputExtensions, sources: [...sources] };
304
+ return {
305
+ directoryExclusionOrigins,
306
+ excludedDirectories: flattenDirectoryExclusionOrigins(
307
+ directoryExclusionOrigins,
308
+ ),
309
+ inputExtensions,
310
+ sources: [...sources],
311
+ };
185
312
  }
186
313
 
187
314
  /**
@@ -215,32 +342,196 @@ export function mergeMembershipPolicyOverlay(
215
342
  applyFlag("allowJs", JAVASCRIPT_INPUT_EXTENSIONS);
216
343
  applyFlag("resolveJsonModule", [".json"]);
217
344
 
218
- const excludedDirectories = [...policy.excludedDirectories];
219
- for (const key of ["outDir", "declarationDir"]) {
345
+ const inheritedOrigins = policy.directoryExclusionOrigins;
346
+ const directoryExclusionOrigins: {
347
+ declarationDir?: string;
348
+ exclude: string[];
349
+ outDir?: string;
350
+ useImplicitOutputExclusions: boolean;
351
+ } = {
352
+ declarationDir: inheritedOrigins?.declarationDir,
353
+ exclude: [...(inheritedOrigins?.exclude ?? policy.excludedDirectories)],
354
+ outDir: inheritedOrigins?.outDir,
355
+ useImplicitOutputExclusions:
356
+ inheritedOrigins?.useImplicitOutputExclusions ?? true,
357
+ };
358
+ for (const key of OUTPUT_DIRECTORY_OPTIONS) {
220
359
  const value = compilerOptions[key];
221
- if (typeof value === "string" && value.length !== 0) {
222
- excludedDirectories.push(path.resolve(baseDir, value));
360
+ if (value === null) {
361
+ delete directoryExclusionOrigins[key];
362
+ } else if (typeof value === "string") {
363
+ directoryExclusionOrigins[key] = resolveConfigDirTemplatePath(
364
+ baseDir,
365
+ value,
366
+ );
223
367
  }
224
368
  }
225
369
  return {
226
- excludedDirectories,
370
+ directoryExclusionOrigins,
371
+ excludedDirectories: flattenDirectoryExclusionOrigins(
372
+ directoryExclusionOrigins,
373
+ ),
227
374
  inputExtensions: [...inputExtensions],
228
375
  sources: policy.sources,
229
376
  };
230
377
  }
231
378
 
379
+ /**
380
+ * Materialize inherited compiler paths whose `${configDir}` owner would move
381
+ * when a generated wrapper becomes the final config in the chain.
382
+ *
383
+ * Only template-bearing options are returned. Ordinary inherited paths are
384
+ * already made absolute while TypeScript parses their declaring config, while
385
+ * template paths deliberately survive until the final consumer is known.
386
+ */
387
+ export function readEffectiveTsconfigTemplateCompilerOptions(
388
+ tsconfig: string,
389
+ ): Record<string, unknown> {
390
+ const resolved = path.resolve(tsconfig);
391
+ const configDir = path.dirname(resolved);
392
+ const output: Record<string, unknown> = {};
393
+ for (const key of CONFIG_DIR_TEMPLATE_SCALAR_OPTIONS) {
394
+ const declared = findDeclaredCompilerOption(resolved, key);
395
+ if (
396
+ declared !== null &&
397
+ typeof declared.value === "string" &&
398
+ startsWithConfigDirTemplate(declared.value)
399
+ ) {
400
+ output[key] = resolveConfigDirTemplatePath(
401
+ declared.baseDir,
402
+ declared.value,
403
+ configDir,
404
+ );
405
+ }
406
+ }
407
+ for (const key of CONFIG_DIR_TEMPLATE_LIST_OPTIONS) {
408
+ const declared = findDeclaredCompilerOption(resolved, key);
409
+ if (
410
+ declared !== null &&
411
+ Array.isArray(declared.value) &&
412
+ declared.value.some(
413
+ (entry) =>
414
+ typeof entry === "string" && startsWithConfigDirTemplate(entry),
415
+ )
416
+ ) {
417
+ output[key] = declared.value.map((entry) =>
418
+ typeof entry === "string"
419
+ ? resolveConfigDirTemplatePath(declared.baseDir, entry, configDir)
420
+ : entry,
421
+ );
422
+ }
423
+ }
424
+
425
+ const declaredPaths = findDeclaredPaths(resolved, new Set());
426
+ if (
427
+ declaredPaths !== null &&
428
+ Object.values(declaredPaths.paths).some(
429
+ (targets) =>
430
+ Array.isArray(targets) &&
431
+ targets.some(
432
+ (target) =>
433
+ typeof target === "string" && startsWithConfigDirTemplate(target),
434
+ ),
435
+ )
436
+ ) {
437
+ output.paths = Object.fromEntries(
438
+ Object.entries(declaredPaths.paths).map(([key, targets]) => [
439
+ key,
440
+ Array.isArray(targets)
441
+ ? targets.map((target) =>
442
+ typeof target === "string"
443
+ ? absolutizePathsTarget(
444
+ declaredPaths.baseDir,
445
+ target,
446
+ configDir,
447
+ )
448
+ : target,
449
+ )
450
+ : targets,
451
+ ]),
452
+ );
453
+ }
454
+ return output;
455
+ }
456
+
457
+ /**
458
+ * Materialize inherited file specifications whose `${configDir}` owner would
459
+ * otherwise move to a generated wrapper's scratch directory.
460
+ */
461
+ export function readEffectiveTsconfigTemplateFileSpecs(
462
+ tsconfig: string,
463
+ ): Record<string, unknown> {
464
+ const resolved = path.resolve(tsconfig);
465
+ const configDir = path.dirname(resolved);
466
+ const output: Record<string, unknown> = {};
467
+ for (const key of CONFIG_DIR_TEMPLATE_FILE_SPECS) {
468
+ const declared = findDeclaredValue(
469
+ resolved,
470
+ (parsed) =>
471
+ Object.prototype.hasOwnProperty.call(parsed, key)
472
+ ? (parsed as Record<string, unknown>)[key]
473
+ : undefined,
474
+ new Set(),
475
+ );
476
+ if (
477
+ declared === null ||
478
+ !Array.isArray(declared.value) ||
479
+ !declared.value.some(
480
+ (entry) =>
481
+ typeof entry === "string" && startsWithConfigDirTemplate(entry),
482
+ )
483
+ ) {
484
+ continue;
485
+ }
486
+ output[key] = declared.value.map((entry) =>
487
+ typeof entry === "string"
488
+ ? absolutizePathsTarget(declared.baseDir, entry, configDir)
489
+ : entry,
490
+ );
491
+ }
492
+ return output;
493
+ }
494
+
232
495
  /**
233
496
  * Anchor a single `paths` target at `baseDir` unless it is already absolute,
234
497
  * normalizing to forward slashes. The `*` wildcard survives `path.resolve` as a
235
498
  * literal segment, so patterns like `./src/*` stay patterns.
236
499
  */
237
- export function absolutizePathsTarget(baseDir: string, target: string): string {
238
- const resolved = path.isAbsolute(target)
239
- ? target
240
- : path.resolve(baseDir, target);
500
+ export function absolutizePathsTarget(
501
+ baseDir: string,
502
+ target: string,
503
+ configDir: string = baseDir,
504
+ ): string {
505
+ const resolved = resolveConfigDirTemplatePath(baseDir, target, configDir);
241
506
  return resolved.replace(/\\/g, "/");
242
507
  }
243
508
 
509
+ /** Resolve one config path with TypeScript's leading `${configDir}` template. */
510
+ export function resolveConfigDirTemplatePath(
511
+ baseDir: string,
512
+ target: string,
513
+ configDir: string = baseDir,
514
+ ): string {
515
+ const template = "${configDir}";
516
+ const normalized = normalizeTypeScriptPathSeparators(target);
517
+ return startsWithConfigDirTemplate(normalized)
518
+ ? path.resolve(configDir, normalized.replace(template, "./"))
519
+ : path.resolve(baseDir, normalized);
520
+ }
521
+
522
+ /** Match the prefix predicate used by TypeScript-Go before substitution. */
523
+ function startsWithConfigDirTemplate(target: string): boolean {
524
+ const template = "${configDir}";
525
+ return (
526
+ target.slice(0, template.length).toLowerCase() === template.toLowerCase()
527
+ );
528
+ }
529
+
530
+ /** Match TypeScript's platform-independent treatment of config separators. */
531
+ function normalizeTypeScriptPathSeparators(target: string): string {
532
+ return target.replace(/\\/g, "/");
533
+ }
534
+
244
535
  /**
245
536
  * The `paths` object found while walking one tsconfig's `extends` chain,
246
537
  * together with the directory of the config that declared it (the anchor for
@@ -251,6 +542,28 @@ interface IDeclaredPaths {
251
542
  paths: Record<string, unknown>;
252
543
  }
253
544
 
545
+ /** Locate one compiler option while retaining its declaring directory. */
546
+ function findDeclaredCompilerOption(
547
+ tsconfig: string,
548
+ key: string,
549
+ ): { baseDir: string; value: unknown } | null {
550
+ const declared = findDeclaredValue(
551
+ tsconfig,
552
+ (parsed) => {
553
+ const options = (parsed as { compilerOptions?: Record<string, unknown> })
554
+ .compilerOptions;
555
+ return options !== undefined &&
556
+ Object.prototype.hasOwnProperty.call(options, key)
557
+ ? { value: options[key] }
558
+ : undefined;
559
+ },
560
+ new Set(),
561
+ );
562
+ return declared === null
563
+ ? null
564
+ : { baseDir: declared.baseDir, value: declared.value.value };
565
+ }
566
+
254
567
  /**
255
568
  * Locate the nearest `compilerOptions.paths` declaration in the `extends` chain
256
569
  * rooted at `tsconfig`. The own config wins over its bases; within an `extends`
@@ -326,7 +639,8 @@ function findDeclaredValue<T>(
326
639
  return { baseDir: path.dirname(canonical), value: own };
327
640
  }
328
641
 
329
- for (const specifier of extendsSpecifiers(parsed.extends).reverse()) {
642
+ for (const rawSpecifier of extendsSpecifiers(parsed.extends).reverse()) {
643
+ const specifier = normalizeTypeScriptPathSeparators(rawSpecifier);
330
644
  const base = resolveExtendsConfig(canonical, specifier);
331
645
  if (base === null) {
332
646
  // Record where a relative or absolute specifier *would* have resolved,
@@ -337,18 +651,11 @@ function findDeclaredValue<T>(
337
651
  // keeps a policy the next run's walk already disagrees with. A bare
338
652
  // specifier is skipped, since it has no single candidate path.
339
653
  if (isRelativeSpecifier(specifier) || path.isAbsolute(specifier)) {
340
- // Both spellings the resolver would have tried, since it falls back to
341
- // `<specifier>.json`. Recording only the literal one leaves the stamp
342
- // unmoved when `./tsconfig.base` later appears as `tsconfig.base.json`,
343
- // which is the same staleness this recording exists to prevent.
344
- const candidate = path.resolve(path.dirname(canonical), specifier);
345
- collect?.add(candidate);
346
- // Case-sensitive, because `resolveExistingExtendsPath` is: it appends
347
- // `.json` unless the spelling already ends in exactly that, so a
348
- // `./base.JSON` specifier really does resolve to `base.JSON.json` on a
349
- // case-sensitive filesystem, and the stamp has to know that name.
350
- if (!candidate.endsWith(".json")) {
351
- collect?.add(`${candidate}.json`);
654
+ for (const candidate of missingExtendsCandidates(
655
+ canonical,
656
+ specifier,
657
+ )) {
658
+ collect?.add(candidate);
352
659
  }
353
660
  }
354
661
  continue;
@@ -361,6 +668,56 @@ function findDeclaredValue<T>(
361
668
  return null;
362
669
  }
363
670
 
671
+ /** Capture every config source independently of which option declares a value. */
672
+ function collectTsconfigSourceSnapshot(
673
+ tsconfig: string,
674
+ seen: Set<string>,
675
+ output: Map<string, string | null>,
676
+ ): void {
677
+ const canonical = resolveRealPath(tsconfig);
678
+ if (seen.has(canonical)) return;
679
+ seen.add(canonical);
680
+
681
+ let contents: string;
682
+ let parsed: { extends?: unknown };
683
+ try {
684
+ contents = fs.readFileSync(canonical, "utf8");
685
+ output.set(canonical, contents);
686
+ parsed = parseJsonc(contents) as typeof parsed;
687
+ } catch {
688
+ output.set(canonical, null);
689
+ return;
690
+ }
691
+ if (typeof parsed !== "object" || parsed === null) return;
692
+
693
+ for (const rawSpecifier of extendsSpecifiers(parsed.extends)) {
694
+ const specifier = normalizeTypeScriptPathSeparators(rawSpecifier);
695
+ const base = resolveExtendsConfig(canonical, specifier);
696
+ if (base !== null) {
697
+ collectTsconfigSourceSnapshot(base, seen, output);
698
+ continue;
699
+ }
700
+ if (isRelativeSpecifier(specifier) || path.isAbsolute(specifier)) {
701
+ for (const candidate of missingExtendsCandidates(canonical, specifier)) {
702
+ if (!output.has(candidate)) output.set(candidate, null);
703
+ }
704
+ }
705
+ }
706
+ }
707
+
708
+ /** Every exact spelling the file resolver probes for a missing config. */
709
+ function missingExtendsCandidates(
710
+ tsconfig: string,
711
+ specifier: string,
712
+ ): string[] {
713
+ const candidate = path.resolve(path.dirname(tsconfig), specifier);
714
+ // Case-sensitive, because `resolveExistingExtendsPath` appends `.json`
715
+ // unless the spelling already ends in exactly that suffix.
716
+ return candidate.endsWith(".json")
717
+ ? [candidate]
718
+ : [candidate, `${candidate}.json`];
719
+ }
720
+
364
721
  /** Normalize the `extends` field into a list of string specifiers. */
365
722
  function extendsSpecifiers(extended: unknown): string[] {
366
723
  if (typeof extended === "string") {
@@ -450,7 +807,10 @@ function resolvePackageManifestTsconfig(
450
807
  return null;
451
808
  }
452
809
  return resolveExistingExtendsPath(
453
- path.resolve(path.dirname(manifestPath), field),
810
+ path.resolve(
811
+ path.dirname(manifestPath),
812
+ normalizeTypeScriptPathSeparators(field),
813
+ ),
454
814
  );
455
815
  }
456
816