ttsc 0.15.2 → 0.15.4

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.
@@ -1,5 +1,7 @@
1
+ import childProcess from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import { createRequire } from "node:module";
4
+ import os from "node:os";
3
5
  import path from "node:path";
4
6
 
5
7
  import { findNearestGoMod } from "../../compiler/internal/paths";
@@ -27,6 +29,7 @@ type ProjectPluginEntry = {
27
29
  type PackageManifest = {
28
30
  dependencies?: Record<string, unknown>;
29
31
  devDependencies?: Record<string, unknown>;
32
+ exports?: unknown;
30
33
  name?: unknown;
31
34
  ttsc?: unknown;
32
35
  };
@@ -579,7 +582,7 @@ function loadPluginEntry(
579
582
  }
580
583
 
581
584
  const request = resolvePluginRequest(specifier, baseDir);
582
- const mod = require(request) as {
585
+ const mod = requirePluginEntry(request, context) as {
583
586
  createTtscPlugin?: TtscPluginFactory;
584
587
  default?: ITtscPlugin | TtscPluginFactory;
585
588
  } & Partial<Record<"plugin", ITtscPlugin | TtscPluginFactory>>;
@@ -608,6 +611,123 @@ function loadPluginEntry(
608
611
  });
609
612
  }
610
613
 
614
+ /**
615
+ * Require a plugin descriptor entry, falling back to `ttsx` when Node cannot
616
+ * load a `.ts` source entry directly.
617
+ *
618
+ * A descriptor entry that is `.ts` source — especially a package root that
619
+ * re-exports a runtime alongside the descriptor — fails Node's loader on its
620
+ * first extensionless import or un-stripped type, and its imports can fan out
621
+ * into a whole transitive graph of source packages. Rather than reimplement
622
+ * that graph build, run the entry through `ttsx`, which already builds each
623
+ * `.ts` dependency on demand. The run is forced plugins-off across the whole
624
+ * graph (`--no-plugins` for the entry, `TTSC_PLUGIN_DESCRIPTOR_LOAD` for every
625
+ * dependency), so the descriptor's own — possibly self-hosting — transform
626
+ * never runs and cannot deadlock. A package that loads directly (a compiled
627
+ * descriptor, or Bun's native `.ts`) never reaches the fallback.
628
+ */
629
+ function requirePluginEntry(
630
+ request: string,
631
+ context: ITtscPluginFactoryContext,
632
+ ): unknown {
633
+ try {
634
+ return require(request);
635
+ } catch (error) {
636
+ if (!TS_SOURCE_PATTERN.test(request)) {
637
+ throw error;
638
+ }
639
+ const descriptor = loadDescriptorViaTtsx(request, context);
640
+ if (descriptor === undefined) {
641
+ throw error;
642
+ }
643
+ return { default: descriptor };
644
+ }
645
+ }
646
+
647
+ const TS_SOURCE_PATTERN = /\.(?:[cm]?ts|tsx)$/i;
648
+
649
+ /**
650
+ * Evaluate a `.ts` plugin descriptor entry in a child `ttsx` process and return
651
+ * the descriptor it produces. A generated shim imports the entry, invokes its
652
+ * factory with `context`, and writes the descriptor as JSON; `ttsx` runs the
653
+ * shim with plugins disabled across the whole graph. Returns `undefined` when
654
+ * `ttsx` is unavailable, so the caller can rethrow the original load error.
655
+ */
656
+ function loadDescriptorViaTtsx(
657
+ request: string,
658
+ context: ITtscPluginFactoryContext,
659
+ ): unknown {
660
+ const node = process.env.TTSC_NODE_BINARY ?? process.execPath;
661
+ const ttsx = process.env.TTSC_TTSX_BINARY;
662
+ if (ttsx === undefined || ttsx.length === 0) {
663
+ return undefined;
664
+ }
665
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-plugin-descriptor-"));
666
+ const out = path.join(dir, "descriptor.json");
667
+ const shim = path.join(dir, "load-descriptor.mts");
668
+ // ttsx type-checks and builds the shim's own project, so it needs a tsconfig
669
+ // to anchor on; a minimal one is enough (the shim is `@ts-nocheck`).
670
+ fs.writeFileSync(
671
+ path.join(dir, "tsconfig.json"),
672
+ JSON.stringify({
673
+ compilerOptions: {
674
+ module: "nodenext",
675
+ moduleResolution: "nodenext",
676
+ skipLibCheck: true,
677
+ target: "es2022",
678
+ },
679
+ }),
680
+ );
681
+ fs.writeFileSync(
682
+ shim,
683
+ [
684
+ `// @ts-nocheck`,
685
+ `import { writeFileSync } from "node:fs";`,
686
+ `import { pathToFileURL } from "node:url";`,
687
+ `const mod = await import(pathToFileURL(process.env.TTSC_PLUGIN_ENTRY).href);`,
688
+ `const context = JSON.parse(process.env.TTSC_PLUGIN_CONTEXT);`,
689
+ `const candidate = mod.createTtscPlugin ?? mod.default ?? mod.plugin ?? mod;`,
690
+ `const descriptor =`,
691
+ ` typeof candidate === "function" ? candidate(context) : candidate;`,
692
+ `writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, JSON.stringify(descriptor));`,
693
+ ``,
694
+ ].join("\n"),
695
+ );
696
+ try {
697
+ const result = childProcess.spawnSync(node, [ttsx, "--no-plugins", shim], {
698
+ cwd: context.projectRoot,
699
+ encoding: "utf8",
700
+ env: {
701
+ ...process.env,
702
+ TTSC_PLUGIN_CONTEXT: JSON.stringify({
703
+ binary: context.binary,
704
+ cwd: context.cwd,
705
+ plugin: context.plugin,
706
+ projectRoot: context.projectRoot,
707
+ tsconfig: context.tsconfig,
708
+ }),
709
+ TTSC_PLUGIN_DESCRIPTOR_LOAD: "1",
710
+ TTSC_PLUGIN_DESCRIPTOR_OUT: out,
711
+ TTSC_PLUGIN_ENTRY: request,
712
+ },
713
+ windowsHide: true,
714
+ });
715
+ if (result.status !== 0 || !fs.existsSync(out)) {
716
+ throw new Error(
717
+ [
718
+ `ttsc: failed to load plugin descriptor "${request}" through ttsx`,
719
+ result.stderr || result.stdout || "",
720
+ ]
721
+ .filter((line) => line.trim().length !== 0)
722
+ .join("\n"),
723
+ );
724
+ }
725
+ return JSON.parse(fs.readFileSync(out, "utf8"));
726
+ } finally {
727
+ fs.rmSync(dir, { force: true, recursive: true });
728
+ }
729
+ }
730
+
611
731
  function withPluginLoaderEnv<T>(run: () => T): T {
612
732
  const previousNode = process.env.TTSC_NODE_BINARY;
613
733
  const previousTtsx = process.env.TTSC_TTSX_BINARY;
@@ -861,9 +981,180 @@ function resolvePluginRequest(specifier: string, projectRoot: string): string {
861
981
  if (isRelativePluginSpecifier(specifier)) {
862
982
  return resolveRealPath(path.resolve(projectRoot, specifier));
863
983
  }
984
+ // A package whose main `.` entry is a runtime barrel cannot double as a
985
+ // plugin descriptor entry: loading it during plugin bootstrap drags the
986
+ // runtime in (and, for a self-hosting transform like typia, deadlocks —
987
+ // loading the transform would have to build the runtime the transform
988
+ // emits). Such a package opts in with a `ttsc` export condition that points
989
+ // at a runtime-free descriptor; honour it here, scoped to plugin resolution.
990
+ const conditioned = resolvePluginExportCondition(specifier, projectRoot);
991
+ if (conditioned !== null) {
992
+ return conditioned;
993
+ }
864
994
  return resolveRealPath(require.resolve(specifier, { paths: [projectRoot] }));
865
995
  }
866
996
 
997
+ /** Condition names ttsc activates when resolving a plugin entry's package `exports`. */
998
+ const PLUGIN_EXPORT_CONDITIONS: readonly string[] = [
999
+ "ttsc",
1000
+ "node",
1001
+ "require",
1002
+ "default",
1003
+ ];
1004
+
1005
+ /**
1006
+ * Resolve a bare plugin specifier under the dedicated `ttsc` export condition.
1007
+ *
1008
+ * A package whose `.` entry is a runtime barrel (e.g. `typia`, whose index
1009
+ * re-exports the whole validator runtime) cannot serve as the plugin descriptor
1010
+ * entry: loading it during plugin bootstrap pulls the runtime in and, for a
1011
+ * self-hosting transform, forms a cycle. Such a package opts in by adding a
1012
+ * `ttsc` condition to its `exports` that points at a runtime-free descriptor:
1013
+ *
1014
+ * "exports": { ".": { "ttsc": "./lib/transform.js", "default": "./lib/index.js" } }
1015
+ *
1016
+ * The condition is honoured ONLY here, scoped to plugin-entry resolution. A
1017
+ * process-wide `--conditions=ttsc` would also redirect the package's normal
1018
+ * `import`s to the descriptor and break its runtime, so it must not be used.
1019
+ *
1020
+ * Returns an absolute path when the package opts in, or `null` to fall back to
1021
+ * the normal `require.resolve` — no `exports`, no `ttsc` branch for the
1022
+ * requested subpath, or an unresolved/missing target — so a package that does
1023
+ * not opt in resolves exactly as it did before.
1024
+ */
1025
+ function resolvePluginExportCondition(
1026
+ specifier: string,
1027
+ baseDir: string,
1028
+ ): string | null {
1029
+ const split = splitPackageSpecifier(specifier);
1030
+ if (split === null) {
1031
+ return null;
1032
+ }
1033
+ const packageJson = resolveDependencyPackageJson(split.packageName, baseDir);
1034
+ if (packageJson === undefined) {
1035
+ return null;
1036
+ }
1037
+ const exportsField = readPackageManifest(packageJson)?.exports;
1038
+ if (exportsField === undefined) {
1039
+ return null;
1040
+ }
1041
+ const target = selectExportTarget(exportsField, split.subpath);
1042
+ // Only take over when the package actually opts in with a `ttsc` condition
1043
+ // for this subpath; otherwise defer so behaviour is unchanged for every
1044
+ // package that does not.
1045
+ if (target === undefined || !containsCondition(target, "ttsc")) {
1046
+ return null;
1047
+ }
1048
+ const resolved = resolveConditionalTarget(target, PLUGIN_EXPORT_CONDITIONS);
1049
+ if (resolved === null || !resolved.startsWith("./")) {
1050
+ return null;
1051
+ }
1052
+ const file = path.resolve(path.dirname(packageJson), resolved);
1053
+ return fs.existsSync(file) ? resolveRealPath(file) : null;
1054
+ }
1055
+
1056
+ /**
1057
+ * Split a bare specifier into its package name and the `.`-prefixed subpath it
1058
+ * addresses (`"typia"` → `.`, `"typia/lib/transform"` → `./lib/transform`,
1059
+ * `"@scope/pkg/sub"` → `./sub`). Returns `null` for a relative/empty specifier
1060
+ * or a malformed scoped name.
1061
+ */
1062
+ function splitPackageSpecifier(
1063
+ specifier: string,
1064
+ ): { packageName: string; subpath: string } | null {
1065
+ if (specifier.length === 0 || specifier.startsWith(".")) {
1066
+ return null;
1067
+ }
1068
+ const segments = specifier.split("/");
1069
+ const nameSegments = specifier.startsWith("@") ? 2 : 1;
1070
+ if (segments.length < nameSegments) {
1071
+ return null;
1072
+ }
1073
+ const rest = segments.slice(nameSegments).join("/");
1074
+ return {
1075
+ packageName: segments.slice(0, nameSegments).join("/"),
1076
+ subpath: rest.length === 0 ? "." : `./${rest}`,
1077
+ };
1078
+ }
1079
+
1080
+ /**
1081
+ * The `exports` entry addressing `subpath`, applying Node's rule that an
1082
+ * `exports` value with no `.`-prefixed keys is sugar for the `.` target.
1083
+ * Returns `undefined` when no entry addresses the subpath.
1084
+ */
1085
+ function selectExportTarget(exportsField: unknown, subpath: string): unknown {
1086
+ if (typeof exportsField === "string" || Array.isArray(exportsField)) {
1087
+ return subpath === "." ? exportsField : undefined;
1088
+ }
1089
+ if (typeof exportsField !== "object" || exportsField === null) {
1090
+ return undefined;
1091
+ }
1092
+ const record = exportsField as Record<string, unknown>;
1093
+ const isSubpathMap = Object.keys(record).some(
1094
+ (key) => key === "." || key.startsWith("./"),
1095
+ );
1096
+ if (!isSubpathMap) {
1097
+ // Conditions object: the whole value is the `.` target.
1098
+ return subpath === "." ? exportsField : undefined;
1099
+ }
1100
+ return subpath in record ? record[subpath] : undefined;
1101
+ }
1102
+
1103
+ /** True when condition key `condition` appears anywhere in a (nested) target. */
1104
+ function containsCondition(target: unknown, condition: string): boolean {
1105
+ if (Array.isArray(target)) {
1106
+ return target.some((entry) => containsCondition(entry, condition));
1107
+ }
1108
+ if (typeof target !== "object" || target === null) {
1109
+ return false;
1110
+ }
1111
+ return Object.entries(target).some(
1112
+ ([key, value]) => key === condition || containsCondition(value, condition),
1113
+ );
1114
+ }
1115
+
1116
+ /**
1117
+ * Resolve a (possibly conditional) export target to a relative file string,
1118
+ * honouring `conditions` — a string is the target, an array is a fallback list,
1119
+ * an object picks the first key in the active condition set (package key order
1120
+ * wins, as Node does), and an explicit `null` blocks the target.
1121
+ */
1122
+ function resolveConditionalTarget(
1123
+ target: unknown,
1124
+ conditions: readonly string[],
1125
+ ): string | null {
1126
+ if (typeof target === "string") {
1127
+ return target;
1128
+ }
1129
+ if (target === null || target === undefined) {
1130
+ return null;
1131
+ }
1132
+ if (Array.isArray(target)) {
1133
+ for (const entry of target) {
1134
+ const resolved = resolveConditionalTarget(entry, conditions);
1135
+ if (resolved !== null) {
1136
+ return resolved;
1137
+ }
1138
+ }
1139
+ return null;
1140
+ }
1141
+ if (typeof target !== "object") {
1142
+ return null;
1143
+ }
1144
+ const active = new Set(conditions);
1145
+ for (const [key, value] of Object.entries(
1146
+ target as Record<string, unknown>,
1147
+ )) {
1148
+ if (active.has(key)) {
1149
+ const resolved = resolveConditionalTarget(value, conditions);
1150
+ if (resolved !== null) {
1151
+ return resolved;
1152
+ }
1153
+ }
1154
+ }
1155
+ return null;
1156
+ }
1157
+
867
1158
  function resolveRealPath(location: string): string {
868
1159
  try {
869
1160
  return fs.realpathSync(location);
@@ -31,6 +31,19 @@ export namespace ITtscCompilerTransformation {
31
31
  * Program.
32
32
  */
33
33
  typescript: Record<string, string>;
34
+
35
+ /**
36
+ * Source files the transform consulted per transformed file, keyed the same
37
+ * way as {@link typescript}. Each entry lists the project-relative or
38
+ * absolute paths whose content influenced that output beyond the file
39
+ * itself — e.g. the declaration files a type-driven code generator read.
40
+ *
41
+ * Optional: only present when the transform native source reported a
42
+ * `dependencies` object in its stdout envelope. Bundler adapters use it to
43
+ * register watch files so type-only imports participate in HMR
44
+ * invalidation. ttsc passes the paths through verbatim.
45
+ */
46
+ dependencies?: Record<string, string[]>;
34
47
  }
35
48
 
36
49
  /** Source-to-source transformation result that completed with diagnostics. */
@@ -49,6 +62,13 @@ export namespace ITtscCompilerTransformation {
49
62
 
50
63
  /** Diagnostics reported during transformation. */
51
64
  diagnostics: ITtscCompilerDiagnostic[];
65
+
66
+ /**
67
+ * Source files the transform consulted per transformed file. Same shape and
68
+ * semantics as {@link ISuccess.dependencies}; may be partial when the
69
+ * transform did not complete its pass.
70
+ */
71
+ dependencies?: Record<string, string[]>;
52
72
  }
53
73
 
54
74
  /** Unexpected host-level error during transformation. */