ttsc 0.10.0 → 0.10.2

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,3 +1,11 @@
1
+ // gen_shims:hand-maintained
2
+ //
3
+ // This shim file mixes generated re-exports with hand-written `go:linkname`
4
+ // declarations targeting unexported `*Checker` methods that the @ttsc/lint
5
+ // engine relies on. gen_shims detects the marker on the first line and skips
6
+ // this file. Remove the marker only if you are intentionally regenerating and
7
+ // willing to re-add the hand-maintained content.
8
+
1
9
  package checker
2
10
 
3
11
  import (
@@ -29,7 +29,7 @@ export function compileProjectInMemory(options: ITtscCompilerContext): {
29
29
  const tsconfig = project.path;
30
30
  const binary = buildNativeCompiler({
31
31
  cacheBaseDir: project.root,
32
- cacheDir: options.cacheDir,
32
+ cacheDir: options.cacheDir ?? options.env?.TTSC_CACHE_DIR,
33
33
  packageRoot: packageRootDir(),
34
34
  });
35
35
  const res = spawnSync(
@@ -12,6 +12,7 @@ import type { TtscCommonOptions } from "../../structures/internal/TtscCommonOpti
12
12
  import { resolveProjectConfig } from "./project/resolveProjectConfig";
13
13
  import { resolveBinary } from "./resolveBinary";
14
14
  import { resolveTsgo } from "./resolveTsgo";
15
+ import { assertSharedHostCompatibility } from "./sharedHostHelpers";
15
16
 
16
17
  /** Merge spawn env without clobbering unrelated vars. */
17
18
  function mergeEnv(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@@ -106,7 +107,7 @@ export function runBuild(
106
107
 
107
108
  if (options.emit === false) {
108
109
  if (compilers.length !== 0) {
109
- assertCompilerHostCompatibility(compilers);
110
+ assertSharedHostCompatibility(compilers, "emit");
110
111
  return appendBuildOutput(
111
112
  checked,
112
113
  buildWithNativeCompilerPlugins(options, execution, compilers),
@@ -120,7 +121,7 @@ export function runBuild(
120
121
 
121
122
  let result: TtscBuildResult;
122
123
  if (compilers.length !== 0) {
123
- assertCompilerHostCompatibility(compilers);
124
+ assertSharedHostCompatibility(compilers, "emit");
124
125
  result = appendBuildOutput(
125
126
  checked,
126
127
  buildWithNativeCompilerPlugins(options, execution, compilers),
@@ -394,61 +395,6 @@ export function appendBuildOutput(
394
395
  });
395
396
  }
396
397
 
397
- function assertCompilerHostCompatibility(
398
- plugins: readonly ITtscLoadedNativePlugin[],
399
- ): void {
400
- const binaries = [...new Set(plugins.map((plugin) => plugin.binary))];
401
- if (binaries.length <= 1) {
402
- return;
403
- }
404
- if (plugins.every(isFirstPartyUtilityTransformPlugin)) {
405
- return;
406
- }
407
- throw new Error(
408
- "ttsc: multiple compiler native backends cannot share one emit pass; " +
409
- "compose transform libraries through one aggregate native host",
410
- );
411
- }
412
-
413
- function isFirstPartyUtilityTransformPlugin(
414
- plugin: ITtscLoadedNativePlugin,
415
- ): boolean {
416
- if (plugin.stage !== "transform") return false;
417
- if (!firstPartyUtilityPluginNames.has(plugin.name)) return false;
418
- const manifest = readNearestPackageManifest(plugin.source);
419
- return manifest?.name === plugin.name;
420
- }
421
-
422
- const firstPartyUtilityPluginNames = new Set([
423
- "@ttsc/banner",
424
- "@ttsc/paths",
425
- "@ttsc/strip",
426
- ]);
427
-
428
- function readNearestPackageManifest(
429
- source: string,
430
- ): { name?: unknown } | undefined {
431
- try {
432
- let current = fs.statSync(source).isDirectory()
433
- ? source
434
- : path.dirname(source);
435
- for (let i = 0; i < 4; i += 1) {
436
- const manifest = path.join(current, "package.json");
437
- if (fs.existsSync(manifest)) {
438
- return JSON.parse(fs.readFileSync(manifest, "utf8")) as {
439
- name?: unknown;
440
- };
441
- }
442
- const parent = path.dirname(current);
443
- if (parent === current) break;
444
- current = parent;
445
- }
446
- } catch {
447
- return undefined;
448
- }
449
- return undefined;
450
- }
451
-
452
398
  function resolveExecutionContext(
453
399
  options: TtscCommonOptions & { tsconfig?: string },
454
400
  ) {
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
5
+
6
+ /**
7
+ * Names of the first-party utility transform plugins that are allowed to share
8
+ * one compiler emit / one source-to-source pass even when their resolved
9
+ * binaries differ. Each entry must be both name-pinned and manifest-pinned: the
10
+ * plugin must declare itself with one of these names AND its source path must
11
+ * resolve to a `package.json` whose `name` field matches.
12
+ *
13
+ * This whitelist exists because `@ttsc/banner`, `@ttsc/paths`, and `@ttsc/strip`
14
+ * are AST-only mutators that operate on the same `*shimast.SourceFile` graph
15
+ * and do not own emit. They are composed through `packages/ttsc/utility/host.go`,
16
+ * so any one of their binaries can host the shared run. Third-party plugins
17
+ * cannot participate in this fast path; they must opt into composition through
18
+ * the explicit `ITtscPlugin.composes` field.
19
+ */
20
+ export const FIRST_PARTY_UTILITY_PLUGIN_NAMES: ReadonlySet<string> = new Set([
21
+ "@ttsc/banner",
22
+ "@ttsc/paths",
23
+ "@ttsc/strip",
24
+ ]);
25
+
26
+ /**
27
+ * Reports whether the given plugin is a first-party utility transform plugin
28
+ * that may participate in the shared compiler host alongside other first-party
29
+ * utility transforms. Returns `false` for `check`-stage plugins, for unknown
30
+ * names, and for plugins whose `source` does not resolve to a matching
31
+ * `package.json`.
32
+ */
33
+ export function isFirstPartyUtilityTransformPlugin(
34
+ plugin: ITtscLoadedNativePlugin,
35
+ ): boolean {
36
+ if (plugin.stage !== "transform") return false;
37
+ if (!FIRST_PARTY_UTILITY_PLUGIN_NAMES.has(plugin.name)) return false;
38
+ const manifest = readNearestPackageManifest(plugin.source);
39
+ return manifest?.name === plugin.name;
40
+ }
41
+
42
+ /**
43
+ * Walks upward from `source` (a directory or file path) up to four parent
44
+ * levels searching for the nearest `package.json`. Returns the parsed manifest
45
+ * object on success, or `undefined` when no manifest is found or parsing
46
+ * fails. The depth bound mirrors the pre-existing inline implementations in
47
+ * `runBuild.ts` and `transformProjectInMemory.ts`.
48
+ */
49
+ export function readNearestPackageManifest(
50
+ source: string,
51
+ ): { name?: unknown } | undefined {
52
+ try {
53
+ let current = fs.statSync(source).isDirectory()
54
+ ? source
55
+ : path.dirname(source);
56
+ for (let i = 0; i < 4; i += 1) {
57
+ const manifest = path.join(current, "package.json");
58
+ if (fs.existsSync(manifest)) {
59
+ return JSON.parse(fs.readFileSync(manifest, "utf8")) as {
60
+ name?: unknown;
61
+ };
62
+ }
63
+ const parent = path.dirname(current);
64
+ if (parent === current) break;
65
+ current = parent;
66
+ }
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * Verifies that all transform plugins in `plugins` either resolve to the same
75
+ * native binary (the common case) or are all first-party utility plugins (the
76
+ * shared compiler-host fast path).
77
+ *
78
+ * Two callers exist with subtly different error wording: the build path
79
+ * (`runBuild.ts`) reports "multiple compiler native backends cannot share one
80
+ * emit pass" while the source-to-source path
81
+ * (`transformProjectInMemory.ts`) reports "cannot share one source-to-source
82
+ * pass". The `pass` argument selects the appropriate phrase so the error
83
+ * message remains diagnostic-grade instead of generic.
84
+ */
85
+ export function assertSharedHostCompatibility(
86
+ plugins: readonly ITtscLoadedNativePlugin[],
87
+ pass: "emit" | "source-to-source",
88
+ ): void {
89
+ const binaries = [...new Set(plugins.map((plugin) => plugin.binary))];
90
+ if (binaries.length <= 1) {
91
+ return;
92
+ }
93
+ if (plugins.every(isFirstPartyUtilityTransformPlugin)) {
94
+ return;
95
+ }
96
+ const phrase =
97
+ pass === "emit"
98
+ ? "multiple compiler native backends cannot share one emit pass"
99
+ : "multiple transform native backends cannot share one source-to-source pass";
100
+ throw new Error(
101
+ "ttsc: " +
102
+ phrase +
103
+ "; compose transform libraries through one aggregate native host",
104
+ );
105
+ }
@@ -16,6 +16,7 @@ import { readProjectConfig } from "./project/readProjectConfig";
16
16
  import { resolveBinary } from "./resolveBinary";
17
17
  import { resolveTsgo } from "./resolveTsgo";
18
18
  import { appendBuildOutput, normalizeBuildOutput } from "./runBuild";
19
+ import { assertSharedHostCompatibility } from "./sharedHostHelpers";
19
20
 
20
21
  /** Transform a project and capture TypeScript source output in memory. */
21
22
  export function transformProjectInMemory(options: ITtscCompilerContext): {
@@ -50,7 +51,7 @@ function transformProjectWithNativeHost(
50
51
  } {
51
52
  const binary = buildNativeCompiler({
52
53
  cacheBaseDir: project.root,
53
- cacheDir: options.cacheDir,
54
+ cacheDir: options.cacheDir ?? options.env?.TTSC_CACHE_DIR,
54
55
  packageRoot: packageRootDir(),
55
56
  });
56
57
  const res = spawnNative(
@@ -123,7 +124,7 @@ function transformProjectWithPlugins(
123
124
  typescript: transformed.typescript,
124
125
  };
125
126
  }
126
- assertTransformHostCompatibility(transformers);
127
+ assertSharedHostCompatibility(transformers, "source-to-source");
127
128
 
128
129
  const plugin = transformers[0]!;
129
130
  const res = spawnNative(
@@ -235,61 +236,6 @@ function serializeNativePlugins(
235
236
  );
236
237
  }
237
238
 
238
- function assertTransformHostCompatibility(
239
- plugins: readonly ITtscLoadedNativePlugin[],
240
- ): void {
241
- const binaries = [...new Set(plugins.map((plugin) => plugin.binary))];
242
- if (binaries.length <= 1) {
243
- return;
244
- }
245
- if (plugins.every(isFirstPartyUtilityTransformPlugin)) {
246
- return;
247
- }
248
- throw new Error(
249
- "ttsc: multiple transform native backends cannot share one source-to-source pass; " +
250
- "compose transform libraries through one aggregate native host",
251
- );
252
- }
253
-
254
- function isFirstPartyUtilityTransformPlugin(
255
- plugin: ITtscLoadedNativePlugin,
256
- ): boolean {
257
- if (plugin.stage !== "transform") return false;
258
- if (!firstPartyUtilityPluginNames.has(plugin.name)) return false;
259
- const manifest = readNearestPackageManifest(plugin.source);
260
- return manifest?.name === plugin.name;
261
- }
262
-
263
- const firstPartyUtilityPluginNames = new Set([
264
- "@ttsc/banner",
265
- "@ttsc/paths",
266
- "@ttsc/strip",
267
- ]);
268
-
269
- function readNearestPackageManifest(
270
- source: string,
271
- ): { name?: unknown } | undefined {
272
- try {
273
- let current = fs.statSync(source).isDirectory()
274
- ? source
275
- : path.dirname(source);
276
- for (let i = 0; i < 4; i += 1) {
277
- const manifest = path.join(current, "package.json");
278
- if (fs.existsSync(manifest)) {
279
- return JSON.parse(fs.readFileSync(manifest, "utf8")) as {
280
- name?: unknown;
281
- };
282
- }
283
- const parent = path.dirname(current);
284
- if (parent === current) break;
285
- current = parent;
286
- }
287
- } catch {
288
- return undefined;
289
- }
290
- return undefined;
291
- }
292
-
293
239
  function nativePluginEnv(
294
240
  options: ITtscCompilerContext,
295
241
  projectRoot: string,
@@ -10,6 +10,7 @@ import type { TtscPluginStage } from "../../structures/TtscPluginStage";
10
10
  import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
11
11
  import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig";
12
12
  import { buildSourcePlugin } from "./buildSourcePlugin";
13
+ import { FIRST_PARTY_UTILITY_PLUGIN_NAMES } from "../../compiler/internal/sharedHostHelpers";
13
14
 
14
15
  type TtscPluginFactory<T = ITtscProjectPluginConfig> = (
15
16
  context: ITtscPluginFactoryContext<T>,
@@ -64,11 +65,14 @@ export function loadProjectPlugins(options: {
64
65
  projectRoot: project.root,
65
66
  tsconfig: project.path,
66
67
  };
67
- const plugins = entries.map((entry) =>
68
- loadPluginEntry(
69
- entry.config,
70
- { ...context, plugin: entry.config },
71
- entry.baseDir,
68
+ const plugins = composePluginSources(
69
+ entries,
70
+ entries.map((entry) =>
71
+ loadPluginEntry(
72
+ entry.config,
73
+ { ...context, plugin: entry.config },
74
+ entry.baseDir,
75
+ ),
72
76
  ),
73
77
  );
74
78
 
@@ -100,6 +104,101 @@ export function loadProjectPlugins(options: {
100
104
  };
101
105
  }
102
106
 
107
+ function composePluginSources(
108
+ entries: readonly ProjectPluginEntry[],
109
+ plugins: readonly ITtscPlugin[],
110
+ ): ITtscPlugin[] {
111
+ const aggregates = plugins
112
+ .map((plugin, index) => ({ index, plugin }))
113
+ .filter(({ plugin }) => Array.isArray(plugin.composes));
114
+ if (aggregates.length === 0) {
115
+ return [...plugins];
116
+ }
117
+ for (const { plugin } of aggregates) {
118
+ for (const target of plugin.composes!) {
119
+ if (typeof target !== "string" || target.trim() === "") {
120
+ throw new Error(
121
+ `ttsc: plugin "${plugin.name}" has an invalid "composes" target; ` +
122
+ `targets must be non-empty plugin names or transform specifiers`,
123
+ );
124
+ }
125
+ }
126
+ }
127
+ // First-party utility plugin names are never legal composes targets for
128
+ // user / third-party plugins. The first-party shared compiler host has its
129
+ // own opt-in path through the manifest-pinned whitelist in
130
+ // `sharedHostHelpers.ts::isFirstPartyUtilityTransformPlugin`. Letting any
131
+ // descriptor borrow the binary of `@ttsc/banner` etc. would bypass that
132
+ // pin and turn `composes` into a supply-chain redirect vector.
133
+ for (const { plugin } of aggregates) {
134
+ for (const target of plugin.composes!) {
135
+ if (FIRST_PARTY_UTILITY_PLUGIN_NAMES.has(target)) {
136
+ throw new Error(
137
+ `ttsc: plugin "${plugin.name}" cannot compose first-party utility "${target}"; ` +
138
+ `first-party utility plugins are composed automatically through their shared compiler host`,
139
+ );
140
+ }
141
+ }
142
+ }
143
+ // Composition is intentionally one hop only: A.composes=[B] sends B to A's
144
+ // binary, but if B.composes=[C] then C uses B's original source and does NOT
145
+ // cascade to A. Detect cycles (A.composes=[B] && B.composes=[A]) and throw,
146
+ // otherwise the silent reswap below would mis-route both plugins.
147
+ for (const { index: i, plugin: a } of aggregates) {
148
+ for (const { index: j, plugin: b } of aggregates) {
149
+ if (i === j) continue;
150
+ const aTransform = entries[i]?.config.transform;
151
+ const bTransform = entries[j]?.config.transform;
152
+ const aComposesB = a.composes!.some((alias) =>
153
+ matchesPluginAlias(alias, b, bTransform),
154
+ );
155
+ const bComposesA = b.composes!.some((alias) =>
156
+ matchesPluginAlias(alias, a, aTransform),
157
+ );
158
+ if (aComposesB && bComposesA) {
159
+ throw new Error(
160
+ `ttsc: plugin composes cycle detected between "${a.name}" and "${b.name}"; ` +
161
+ `each plugin lists the other in its "composes" array — composition is one hop only, not transitive`,
162
+ );
163
+ }
164
+ }
165
+ }
166
+ return plugins.map((plugin, index) => {
167
+ const transform = entries[index]?.config.transform;
168
+ const matchingAggregates = aggregates.filter(
169
+ ({ index: aggregateIndex, plugin: aggregatePlugin }) =>
170
+ aggregateIndex !== index &&
171
+ aggregatePlugin.composes!.some((alias) =>
172
+ matchesPluginAlias(alias, plugin, transform),
173
+ ),
174
+ );
175
+ if (matchingAggregates.length > 1) {
176
+ throw new Error(
177
+ `ttsc: plugin "${plugin.name}" is composed by multiple aggregate plugins; ` +
178
+ `each plugin entry can be redirected to only one aggregate native host`,
179
+ );
180
+ }
181
+ const aggregate = matchingAggregates[0];
182
+ return aggregate === undefined
183
+ ? plugin
184
+ : {
185
+ ...plugin,
186
+ source: aggregate.plugin.source,
187
+ };
188
+ });
189
+ }
190
+
191
+ function matchesPluginAlias(
192
+ alias: string,
193
+ plugin: ITtscPlugin,
194
+ transform: ITtscProjectPluginConfig["transform"],
195
+ ): boolean {
196
+ return (
197
+ alias === plugin.name ||
198
+ (typeof transform === "string" && alias === transform)
199
+ );
200
+ }
201
+
103
202
  export function hasProjectPluginEntries(
104
203
  project: ITtscParsedProjectConfig,
105
204
  entries?: readonly ITtscProjectPluginConfig[] | false,
@@ -45,6 +45,17 @@ export interface ITtscPlugin {
45
45
  */
46
46
  source: string;
47
47
 
48
+ /**
49
+ * Other transform plugin names or transform specifiers that this native
50
+ * sidecar can execute in the same compiler pass.
51
+ *
52
+ * Package auto-discovery may find multiple transform packages that must share
53
+ * one emit host. When one descriptor lists another entry here, ttsc keeps the
54
+ * original plugin config in `--plugins-json` but points the composed entry at
55
+ * this descriptor's native source so both entries resolve to one binary.
56
+ */
57
+ composes?: string[];
58
+
48
59
  /**
49
60
  * Pipeline stage implemented by the sidecar.
50
61
  *
@@ -429,18 +429,38 @@ func main() {
429
429
  shimHeaderBuilder.WriteString("\n")
430
430
 
431
431
  shimGoPath := path.Join(shimDirPath, "shim.go")
432
- file, err := os.Create(shimGoPath)
433
- if err != nil {
434
- log.Fatalf("gen_shims: opening %v for write: %v", shimGoPath, err)
435
- }
436
- file.WriteString(shimHeaderBuilder.String())
437
- file.WriteString(shimBuilder.String())
438
- if err := file.Close(); err != nil {
439
- log.Fatalf("gen_shims: closing %v: %v", shimGoPath, err)
432
+ if isHandMaintained(shimGoPath) {
433
+ fmt.Fprintf(os.Stderr, "gen_shims: skipping %s (hand-maintained marker present; remove '// gen_shims:hand-maintained' to regenerate)\n", shimGoPath)
434
+ } else {
435
+ file, err := os.Create(shimGoPath)
436
+ if err != nil {
437
+ log.Fatalf("gen_shims: opening %v for write: %v", shimGoPath, err)
438
+ }
439
+ file.WriteString(shimHeaderBuilder.String())
440
+ file.WriteString(shimBuilder.String())
441
+ if err := file.Close(); err != nil {
442
+ log.Fatalf("gen_shims: closing %v: %v", shimGoPath, err)
443
+ }
444
+ fmt.Fprintf(os.Stdout, "gen_shims: wrote %s (%d bytes)\n", shimGoPath, len(shimBuilder.String()))
440
445
  }
441
- fmt.Fprintf(os.Stdout, "gen_shims: wrote %s (%d bytes)\n", shimGoPath, len(shimBuilder.String()))
442
446
 
443
447
  shimHeaderBuilder.Reset()
444
448
  shimBuilder.Reset()
445
449
  }
446
450
  }
451
+
452
+ // isHandMaintained reports whether the given shim file opts out of regeneration
453
+ // by carrying the magic marker `// gen_shims:hand-maintained` within its first
454
+ // 200 bytes. Hand-maintained shim files combine generated re-exports with
455
+ // `go:linkname` declarations or other content that gen_shims cannot reproduce;
456
+ // silently overwriting them would break consumers (e.g. @ttsc/lint).
457
+ func isHandMaintained(shimGoPath string) bool {
458
+ file, err := os.Open(shimGoPath)
459
+ if err != nil {
460
+ return false
461
+ }
462
+ defer file.Close()
463
+ buf := make([]byte, 200)
464
+ n, _ := file.Read(buf)
465
+ return strings.Contains(string(buf[:n]), "gen_shims:hand-maintained")
466
+ }