ttsc 0.10.2 → 0.11.0-dev.20260517

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 (106) hide show
  1. package/README.md +8 -0
  2. package/cmd/ttsc/api_compile.go +106 -106
  3. package/cmd/ttsc/api_transform.go +54 -54
  4. package/cmd/ttsc/main.go +103 -103
  5. package/cmd/ttscserver/helpers_test.go +27 -0
  6. package/cmd/ttscserver/main.go +154 -0
  7. package/cmd/ttscserver/run_lsp_reports_getwd_error_test.go +34 -0
  8. package/cmd/ttscserver/run_lsp_reports_server_error_test.go +42 -0
  9. package/driver/lsp_envelope.go +136 -0
  10. package/driver/lsp_frame.go +121 -0
  11. package/driver/lsp_plugin.go +150 -0
  12. package/driver/lsp_proxy.go +394 -0
  13. package/driver/lsp_server.go +197 -0
  14. package/go.mod +4 -0
  15. package/go.sum +4 -0
  16. package/internal/cwd/cwd.go +8 -8
  17. package/lib/TtscCompiler.d.ts +15 -16
  18. package/lib/TtscCompiler.js +15 -16
  19. package/lib/TtscCompiler.js.map +1 -1
  20. package/lib/compiler/internal/compileProjectInMemory.d.ts +4 -1
  21. package/lib/compiler/internal/compileProjectInMemory.js +4 -1
  22. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  23. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  24. package/lib/compiler/internal/runBuild.js +43 -1
  25. package/lib/compiler/internal/runBuild.js.map +1 -1
  26. package/lib/compiler/internal/sharedHostHelpers.d.ts +12 -12
  27. package/lib/compiler/internal/sharedHostHelpers.js +12 -12
  28. package/lib/defineTtscPlugin.d.ts +18 -0
  29. package/lib/defineTtscPlugin.js +7 -0
  30. package/lib/defineTtscPlugin.js.map +1 -0
  31. package/lib/index.d.ts +4 -3
  32. package/lib/index.js +4 -3
  33. package/lib/index.js.map +1 -1
  34. package/lib/launcher/internal/prepareExecution.js +1 -1
  35. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  36. package/lib/launcher/internal/resolveTtscserverBinary.d.ts +12 -0
  37. package/lib/launcher/internal/resolveTtscserverBinary.js +43 -0
  38. package/lib/launcher/internal/resolveTtscserverBinary.js.map +1 -0
  39. package/lib/launcher/internal/runTtsc.js +47 -8
  40. package/lib/launcher/internal/runTtsc.js.map +1 -1
  41. package/lib/launcher/internal/runTtscserver.d.ts +20 -0
  42. package/lib/launcher/internal/runTtscserver.js +125 -0
  43. package/lib/launcher/internal/runTtscserver.js.map +1 -0
  44. package/lib/launcher/internal/runTtsx.js +1 -1
  45. package/lib/launcher/internal/runTtsx.js.map +1 -1
  46. package/lib/launcher/ttscserver.d.ts +2 -0
  47. package/lib/launcher/ttscserver.js +9 -0
  48. package/lib/launcher/ttscserver.js.map +1 -0
  49. package/lib/plugin/internal/buildSourcePlugin.d.ts +9 -0
  50. package/lib/plugin/internal/buildSourcePlugin.js +197 -2
  51. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  52. package/lib/plugin/internal/loadProjectPlugins.js +86 -8
  53. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  54. package/lib/structures/ITtscCompilerContext.d.ts +1 -1
  55. package/lib/structures/ITtscCompilerDiagnostic.d.ts +3 -3
  56. package/lib/structures/ITtscCompilerResult.d.ts +10 -22
  57. package/lib/structures/ITtscCompilerTransformation.d.ts +2 -2
  58. package/lib/structures/ITtscPlugin.d.ts +37 -4
  59. package/lib/structures/ITtscPlugin.js +0 -4
  60. package/lib/structures/ITtscPlugin.js.map +1 -1
  61. package/lib/structures/ITtscPluginContributor.d.ts +26 -0
  62. package/lib/structures/ITtscPluginContributor.js +3 -0
  63. package/lib/structures/ITtscPluginContributor.js.map +1 -0
  64. package/lib/structures/ITtscPluginFactoryContext.d.ts +5 -5
  65. package/lib/structures/ITtscProjectPluginConfig.d.ts +2 -2
  66. package/lib/structures/TtscPluginStage.d.ts +4 -3
  67. package/lib/structures/index.d.ts +1 -0
  68. package/lib/structures/index.js +1 -0
  69. package/lib/structures/index.js.map +1 -1
  70. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +3 -0
  71. package/lib/structures/internal/TtscBuildOptions.d.ts +13 -0
  72. package/lib/structures/internal/TtscCommonOptions.d.ts +5 -2
  73. package/package.json +10 -9
  74. package/shim/lsp/extra-shim.json +4 -0
  75. package/shim/lsp/go.mod +16 -0
  76. package/shim/lsp/go.sum +26 -0
  77. package/shim/lsp/shim.go +42 -0
  78. package/shim/printer/shim.go +5 -5
  79. package/src/TtscCompiler.ts +15 -16
  80. package/src/compiler/internal/compileProjectInMemory.ts +4 -1
  81. package/src/compiler/internal/project/readProjectConfig.ts +0 -1
  82. package/src/compiler/internal/runBuild.ts +43 -1
  83. package/src/compiler/internal/sharedHostHelpers.ts +12 -12
  84. package/src/defineTtscPlugin.ts +26 -0
  85. package/src/index.ts +4 -3
  86. package/src/launcher/internal/prepareExecution.ts +1 -1
  87. package/src/launcher/internal/resolveTtscserverBinary.ts +51 -0
  88. package/src/launcher/internal/runTtsc.ts +53 -11
  89. package/src/launcher/internal/runTtscserver.ts +92 -0
  90. package/src/launcher/internal/runTtsx.ts +1 -1
  91. package/src/launcher/ttscserver.ts +8 -0
  92. package/src/plugin/internal/buildSourcePlugin.ts +231 -12
  93. package/src/plugin/internal/loadProjectPlugins.ts +108 -8
  94. package/src/structures/ITtscCompilerContext.ts +1 -1
  95. package/src/structures/ITtscCompilerDiagnostic.ts +3 -3
  96. package/src/structures/ITtscCompilerResult.ts +10 -22
  97. package/src/structures/ITtscCompilerTransformation.ts +2 -2
  98. package/src/structures/ITtscPlugin.ts +37 -11
  99. package/src/structures/ITtscPluginContributor.ts +27 -0
  100. package/src/structures/ITtscPluginFactoryContext.ts +5 -5
  101. package/src/structures/ITtscProjectPluginConfig.ts +2 -2
  102. package/src/structures/TtscPluginStage.ts +4 -3
  103. package/src/structures/index.ts +1 -0
  104. package/src/structures/internal/ITtscLoadedNativePlugin.ts +3 -0
  105. package/src/structures/internal/TtscBuildOptions.ts +13 -0
  106. package/src/structures/internal/TtscCommonOptions.ts +5 -2
@@ -12,12 +12,43 @@ const TSGO_GO_MODULE_PATH = "github.com/microsoft/typescript-go";
12
12
  const PRUNE_DIRS = new Set(["node_modules", ".git", ".ttsc"]);
13
13
  const GENERATED_WORKSPACE_FILES = new Set(["go.work", "go.work.sum"]);
14
14
 
15
+ // Go env vars that change the produced binary. Hashed into the plugin
16
+ // build cache key so cross-compile target / build tags / cgo toggle
17
+ // don't collide with the native-platform key.
18
+ const GO_BUILD_ENV_KEYS: readonly string[] = [
19
+ "GOOS",
20
+ "GOARCH",
21
+ "GOAMD64",
22
+ "GOARM",
23
+ "GO386",
24
+ "GOMIPS",
25
+ "GOMIPS64",
26
+ "GOPPC64",
27
+ "GORISCV64",
28
+ "GOWASM",
29
+ "GOFLAGS",
30
+ "GOEXPERIMENT",
31
+ "CGO_ENABLED",
32
+ "GOTOOLCHAIN",
33
+ ];
34
+ const CONTRIBUTIONS_FILE_NAME = "ttsc_contributions.go";
35
+ const CONTRIB_DIRNAME = "contrib";
36
+
37
+ /** One contributor's resolved Go source plus its target sub-package name. */
38
+ export interface ITtscBuildContributor {
39
+ /** Sub-package suffix: scratch lands at `<host>/contrib/<name>/`. */
40
+ name: string;
41
+ /** Absolute path to the contributor's source directory. */
42
+ source: string;
43
+ }
44
+
15
45
  /** Build one Go source plugin into a cached executable. */
16
46
  export function buildSourcePlugin(opts: {
17
47
  source: string;
18
48
  pluginName: string;
19
49
  baseDir: string;
20
50
  cacheDir?: string;
51
+ contributors?: readonly ITtscBuildContributor[];
21
52
  label?: string;
22
53
  overlayDirs?: readonly string[];
23
54
  quiet?: boolean;
@@ -26,9 +57,11 @@ export function buildSourcePlugin(opts: {
26
57
  }): string {
27
58
  const { dir, entry, source } = resolveSourceBuildTarget(opts);
28
59
  const overlayDirs = opts.overlayDirs ?? findTtscOverlayDirs();
60
+ const contributors = opts.contributors ?? [];
29
61
  const goBinary = resolveGoCompiler();
30
62
  ensureExecutableGoToolchain(goBinary);
31
63
  const key = computeCacheKey({
64
+ contributors,
32
65
  dir,
33
66
  entry,
34
67
  goBinary,
@@ -46,8 +79,14 @@ export function buildSourcePlugin(opts: {
46
79
  fs.mkdirSync(cacheDir, { recursive: true });
47
80
  const label = opts.label ?? "source plugin";
48
81
  if (opts.quiet !== true) {
82
+ const extra =
83
+ contributors.length === 0
84
+ ? ""
85
+ : ` + ${contributors.length} contributor(s): ${contributors
86
+ .map((c) => c.name)
87
+ .join(", ")}`;
49
88
  process.stderr.write(
50
- `ttsc: building ${label} "${opts.pluginName}" from ${source} (this runs once per cache key)\n`,
89
+ `ttsc: building ${label} "${opts.pluginName}" from ${source}${extra} (this runs once per cache key)\n`,
51
90
  );
52
91
  }
53
92
 
@@ -56,6 +95,16 @@ export function buildSourcePlugin(opts: {
56
95
  );
57
96
  try {
58
97
  materializeScratchDir(dir, scratchDir);
98
+ const goModReader = createGoModReader(goBinary, opts.pluginName);
99
+ if (contributors.length > 0) {
100
+ mergeContributors({
101
+ contributors,
102
+ entry,
103
+ goModReader,
104
+ pluginName: opts.pluginName,
105
+ scratchDir,
106
+ });
107
+ }
59
108
  writeGoWork(scratchDir, overlayDirs, goBinary, opts.pluginName);
60
109
  const scratchBinaryName =
61
110
  process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
@@ -68,6 +117,128 @@ export function buildSourcePlugin(opts: {
68
117
  }
69
118
  }
70
119
 
120
+ /**
121
+ * Copy every contributor's Go source into a sub-package of the host module and
122
+ * synthesize a blank-import file alongside the host's entry package so each
123
+ * contributor's `init()` runs before `main`.
124
+ *
125
+ * - Sources land at `<scratch>/<CONTRIB_DIRNAME>/<name>/` (recursive copy with
126
+ * the same pruning rules used for the host source).
127
+ * - The entry directory receives `<CONTRIBUTIONS_FILE_NAME>` containing one
128
+ * blank-import per contributor. The host's module path is read from the
129
+ * materialized go.mod, so the import path is always correct for the host
130
+ * plugin's actual module declaration.
131
+ * - Contributors that ship their own `go.mod` are rejected — the design relies on
132
+ * the contributor living inside the host's module so that workspace overlay
133
+ * rules and the host's `go.sum` cover transitive dependencies. This also
134
+ * closes the supply-chain hole where a contributor could otherwise pull in
135
+ * arbitrary Go modules.
136
+ */
137
+ function mergeContributors(opts: {
138
+ contributors: readonly ITtscBuildContributor[];
139
+ entry: string;
140
+ goModReader: GoModReader;
141
+ pluginName: string;
142
+ scratchDir: string;
143
+ }): void {
144
+ const hostModulePath = opts.goModReader.read(opts.scratchDir).modulePath;
145
+ if (hostModulePath === null || hostModulePath === "") {
146
+ throw new Error(
147
+ `ttsc: plugin "${opts.pluginName}" cannot accept contributors because its module ` +
148
+ `root has no resolvable go.mod module path`,
149
+ );
150
+ }
151
+ const contribRoot = path.join(opts.scratchDir, CONTRIB_DIRNAME);
152
+ // Refuse to merge when the host plugin's own source already owns a
153
+ // `contrib/` directory. We'd otherwise silently merge contributor
154
+ // files into a pre-populated host package and ship a hybrid binary
155
+ // whose contents nobody declared. Loud failure is the only safe
156
+ // option — the host plugin must rename its directory or the
157
+ // contributor system must use a different sub-package root.
158
+ if (fs.existsSync(contribRoot)) {
159
+ throw new Error(
160
+ `ttsc: plugin "${opts.pluginName}" already ships a ${CONTRIB_DIRNAME}/ directory in its source; ` +
161
+ `contributor merge would silently overwrite. Rename the host plugin's directory to a different name.`,
162
+ );
163
+ }
164
+ fs.mkdirSync(contribRoot, { recursive: true });
165
+ // Sort contributors by name so the synthesized `ttsc_contributions.go`
166
+ // emits blank imports in a deterministic order independent of
167
+ // declaration order. The cache key is already sort-stable
168
+ // (`computeCacheKey` sorts contributors by name), so without this
169
+ // matching sort the SAME cache key could correspond to two distinct
170
+ // binaries whose `init()` sequence across contributors differs by
171
+ // import order.
172
+ const sortedContributors = [...opts.contributors].sort((a, b) =>
173
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
174
+ );
175
+ const imports: string[] = [];
176
+ for (const contributor of sortedContributors) {
177
+ if (fs.existsSync(path.join(contributor.source, "go.mod"))) {
178
+ throw new Error(
179
+ `ttsc: plugin "${opts.pluginName}" contributor "${contributor.name}" must ship Go ` +
180
+ `source as a package, not a module (go.mod found at ${contributor.source}/go.mod). ` +
181
+ `Remove go.mod so the contributor compiles inside the host module's dependency graph.`,
182
+ );
183
+ }
184
+ const target = path.join(contribRoot, contributor.name);
185
+ if (fs.existsSync(target)) {
186
+ // Defensive: validatePluginContributors already rejects duplicate
187
+ // names, and the contribRoot-existence guard above blocks the
188
+ // host plugin from pre-shipping a `contrib/` directory. Reaching
189
+ // this branch implies an upstream contract break. Fail loud
190
+ // rather than overwrite.
191
+ throw new Error(
192
+ `ttsc: plugin "${opts.pluginName}" contributor "${contributor.name}" target ${target} already exists; ` +
193
+ `contributor names must be unique within one plugin build`,
194
+ );
195
+ }
196
+ fs.cpSync(contributor.source, target, {
197
+ recursive: true,
198
+ filter: (src) => {
199
+ const base = path.basename(src);
200
+ if (shouldPruneDirectory(base)) return false;
201
+ if (shouldOmitSourceFile(base)) return false;
202
+ return true;
203
+ },
204
+ });
205
+ imports.push(`${hostModulePath}/${CONTRIB_DIRNAME}/${contributor.name}`);
206
+ }
207
+ const entryDir = path.resolve(opts.scratchDir, opts.entry);
208
+ fs.mkdirSync(entryDir, { recursive: true });
209
+ const contributionsPath = path.join(entryDir, CONTRIBUTIONS_FILE_NAME);
210
+ // Same reasoning as the contribRoot guard: when entry resolves to the
211
+ // module root (`entry === "."`), entryDir == scratchDir and a
212
+ // pre-existing `ttsc_contributions.go` from the host plugin's own
213
+ // source would be silently overwritten by the generator below.
214
+ if (fs.existsSync(contributionsPath)) {
215
+ throw new Error(
216
+ `ttsc: plugin "${opts.pluginName}" already ships ${CONTRIBUTIONS_FILE_NAME} in its entry package; ` +
217
+ `that filename is reserved for the contributor blank-import generator. Rename the host's file.`,
218
+ );
219
+ }
220
+ writeContributionsFile(contributionsPath, imports);
221
+ }
222
+
223
+ function writeContributionsFile(filePath: string, imports: string[]): void {
224
+ const importLines = imports
225
+ .map((spec) => `\t_ ${JSON.stringify(spec)}`)
226
+ .join("\n");
227
+ const body = `// Code generated by ttsc — DO NOT EDIT.
228
+ //
229
+ // This file is synthesized by ttsc's plugin builder when the host plugin
230
+ // descriptor declares "contributors". The blank imports below pull each
231
+ // contributor sub-package into the build so its init() runs before main.
232
+
233
+ package main
234
+
235
+ import (
236
+ ${importLines}
237
+ )
238
+ `;
239
+ fs.writeFileSync(filePath, body, "utf8");
240
+ }
241
+
71
242
  function publishBuiltBinary(builtBinary: string, binaryPath: string): void {
72
243
  const pending = `${binaryPath}.${process.pid}.${Date.now()}-${Math.random()
73
244
  .toString(16)
@@ -88,6 +259,30 @@ function publishBuiltBinary(builtBinary: string, binaryPath: string): void {
88
259
  return;
89
260
  }
90
261
  throw error;
262
+ } finally {
263
+ // Best-effort sweep of any leftover `.tmp` siblings from a prior
264
+ // crash between copyFileSync and renameSync. Same-directory pending
265
+ // names guarantee the rename stays a same-filesystem atomic op, so
266
+ // we accept the GC cost rather than move pending files to os.tmpdir.
267
+ pruneOrphanPendingBinaries(binaryPath);
268
+ }
269
+ }
270
+
271
+ function pruneOrphanPendingBinaries(binaryPath: string): void {
272
+ // Only sweep pending files owned by THIS process. Concurrent ttsc
273
+ // invocations (two `ttsc --watch` shells against the same project)
274
+ // may have their own `<binary>.<their-pid>.*.tmp` mid-flight, and
275
+ // deleting them would race their renameSync into ENOENT.
276
+ try {
277
+ const dir = path.dirname(binaryPath);
278
+ const prefix = `${path.basename(binaryPath)}.${process.pid}.`;
279
+ for (const name of fs.readdirSync(dir)) {
280
+ if (name.startsWith(prefix) && name.endsWith(".tmp")) {
281
+ fs.rmSync(path.join(dir, name), { force: true });
282
+ }
283
+ }
284
+ } catch {
285
+ // Best-effort; never mask the underlying publish outcome.
91
286
  }
92
287
  }
93
288
 
@@ -171,20 +366,12 @@ function writeGoWork(
171
366
  pluginName: string,
172
367
  ): void {
173
368
  const goModReader = createGoModReader(goBinary, pluginName);
174
- validateSourceReplacements(
175
- scratchDir,
176
- useDirs,
177
- goModReader,
178
- pluginName,
179
- );
369
+ validateSourceReplacements(scratchDir, useDirs, goModReader, pluginName);
180
370
  const useLines = ["\t."];
181
371
  for (const dir of useDirs) {
182
372
  useLines.push(`\t${dir.replace(/\\/g, "/")}`);
183
373
  }
184
- const replaceLines = sourceBuildWorkspaceReplacements(
185
- useDirs,
186
- goModReader,
187
- );
374
+ const replaceLines = sourceBuildWorkspaceReplacements(useDirs, goModReader);
188
375
  const replaceBlock =
189
376
  replaceLines.length === 0 ? "" : `\n\n${replaceLines.join("\n")}\n`;
190
377
  const goWork = `go 1.26\n\nuse (\n${useLines.join("\n")}\n)${replaceBlock}`;
@@ -582,6 +769,7 @@ function resolveGoCompiler(): string {
582
769
  }
583
770
 
584
771
  export function computeCacheKey(inputs: {
772
+ contributors?: readonly ITtscBuildContributor[];
585
773
  dir: string;
586
774
  entry: string;
587
775
  goBinary?: string;
@@ -597,10 +785,34 @@ export function computeCacheKey(inputs: {
597
785
  if (inputs.goBinary !== undefined) {
598
786
  hash.update(`go=${resolveGoCompilerIdentity(inputs.goBinary)}\n`);
599
787
  }
788
+ // Hash the Go env vars that change `go build`'s output (cross-compile
789
+ // target, build tags, cgo toggle, etc.). Without this, a user setting
790
+ // `GOOS=linux GOARCH=arm64` on a darwin host would write a Linux-arm64
791
+ // binary into the same cache slot as the native build, and the next
792
+ // native invocation would spawn an unrunnable artifact.
793
+ for (const key of GO_BUILD_ENV_KEYS) {
794
+ const value = process.env[key];
795
+ if (value !== undefined && value !== "") {
796
+ hash.update(`${key}=${value}\n`);
797
+ }
798
+ }
600
799
  hashSourceDirectory(hash, "plugin", inputs.dir);
601
800
  for (const [index, dir] of [...(inputs.overlayDirs ?? [])].sort().entries()) {
602
801
  hashSourceDirectory(hash, `overlay:${index}`, dir);
603
802
  }
803
+ // Hash contributors in sorted-by-name order so two consumers with the
804
+ // same logical set produce the same key regardless of declaration order
805
+ // in the host's plugin descriptor.
806
+ const sortedContributors = [...(inputs.contributors ?? [])].sort((a, b) =>
807
+ a.name === b.name ? 0 : a.name < b.name ? -1 : 1,
808
+ );
809
+ for (const contributor of sortedContributors) {
810
+ hashSourceDirectory(
811
+ hash,
812
+ `contributor:${contributor.name}`,
813
+ contributor.source,
814
+ );
815
+ }
604
816
  return hash.digest("hex").slice(0, 32);
605
817
  }
606
818
 
@@ -651,7 +863,14 @@ function shouldPruneDirectory(name: string): boolean {
651
863
  }
652
864
 
653
865
  function shouldOmitSourceFile(name: string): boolean {
654
- return GENERATED_WORKSPACE_FILES.has(name);
866
+ if (GENERATED_WORKSPACE_FILES.has(name)) return true;
867
+ // npm-pack tarballs and macOS/Windows editor sidecars are local
868
+ // build artifacts that drift independently of the Go source. They
869
+ // would otherwise enter the cache key and bust the cached binary on
870
+ // every unrelated `npm pack` or editor save.
871
+ if (name.endsWith(".tgz") || name.endsWith(".tar.gz")) return true;
872
+ if (name === ".DS_Store" || name === "Thumbs.db") return true;
873
+ return false;
655
874
  }
656
875
 
657
876
  function isHashableFile(name: string): boolean {
@@ -1,8 +1,9 @@
1
- import { createRequire } from "node:module";
2
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
 
5
5
  import { readProjectConfig } from "../../compiler/internal/project/readProjectConfig";
6
+ import { FIRST_PARTY_UTILITY_PLUGIN_NAMES } from "../../compiler/internal/sharedHostHelpers";
6
7
  import type { ITtscPlugin } from "../../structures/ITtscPlugin";
7
8
  import type { ITtscPluginFactoryContext } from "../../structures/ITtscPluginFactoryContext";
8
9
  import type { ITtscProjectPluginConfig } from "../../structures/ITtscProjectPluginConfig";
@@ -10,7 +11,6 @@ import type { TtscPluginStage } from "../../structures/TtscPluginStage";
10
11
  import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
11
12
  import type { ITtscParsedProjectConfig } from "../../structures/internal/ITtscParsedProjectConfig";
12
13
  import { buildSourcePlugin } from "./buildSourcePlugin";
13
- import { FIRST_PARTY_UTILITY_PLUGIN_NAMES } from "../../compiler/internal/sharedHostHelpers";
14
14
 
15
15
  type TtscPluginFactory<T = ITtscProjectPluginConfig> = (
16
16
  context: ITtscPluginFactoryContext<T>,
@@ -82,9 +82,11 @@ export function loadProjectPlugins(options: {
82
82
  plugins.forEach((plugin, index) => {
83
83
  const stage = resolvePluginStage(plugin);
84
84
  validatePluginSource(plugin);
85
+ const contributors = validatePluginContributors(plugin);
85
86
  const binary = buildSourcePlugin({
86
87
  baseDir: context.projectRoot,
87
88
  cacheDir: options.cacheDir,
89
+ contributors,
88
90
  pluginName: plugin.name,
89
91
  source: plugin.source,
90
92
  ttscVersion,
@@ -93,6 +95,7 @@ export function loadProjectPlugins(options: {
93
95
  nativePlugins.push({
94
96
  binary,
95
97
  config: entries[index]!.config,
98
+ contributors,
96
99
  name: plugin.name,
97
100
  source: plugin.source,
98
101
  stage,
@@ -179,12 +182,25 @@ function composePluginSources(
179
182
  );
180
183
  }
181
184
  const aggregate = matchingAggregates[0];
182
- return aggregate === undefined
183
- ? plugin
184
- : {
185
- ...plugin,
186
- source: aggregate.plugin.source,
187
- };
185
+ if (aggregate === undefined) {
186
+ return plugin;
187
+ }
188
+ // A composed plugin's source is rerouted to the aggregate's binary,
189
+ // so its own `contributors` would link into a different host than
190
+ // it was authored against. The "one binary" guarantee in the
191
+ // protocol doc holds only when the composed plugin defers entirely
192
+ // to the aggregate; reject early instead of silently producing two
193
+ // diverging binaries.
194
+ if (plugin.contributors && plugin.contributors.length > 0) {
195
+ throw new Error(
196
+ `ttsc: plugin "${plugin.name}" is composed by "${aggregate.plugin.name}" but declares its own "contributors"; ` +
197
+ `move the contributors onto the aggregate plugin or drop the composes redirect`,
198
+ );
199
+ }
200
+ return {
201
+ ...plugin,
202
+ source: aggregate.plugin.source,
203
+ };
188
204
  });
189
205
  }
190
206
 
@@ -511,10 +527,94 @@ function validatePluginSource(plugin: ITtscPlugin): void {
511
527
  }
512
528
  }
513
529
 
530
+ const CONTRIBUTOR_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
531
+
532
+ function validatePluginContributors(
533
+ plugin: ITtscPlugin,
534
+ ): readonly { name: string; source: string }[] | undefined {
535
+ const contributors = plugin.contributors;
536
+ if (contributors === undefined) return undefined;
537
+ if (!Array.isArray(contributors)) {
538
+ throw new Error(
539
+ `ttsc: plugin "${plugin.name}" "contributors" must be an array of { name, source } entries`,
540
+ );
541
+ }
542
+ if (contributors.length === 0) return undefined;
543
+ const seen = new Set<string>();
544
+ const out: { name: string; source: string }[] = [];
545
+ for (const [index, entry] of contributors.entries()) {
546
+ if (typeof entry !== "object" || entry === null) {
547
+ throw new Error(
548
+ `ttsc: plugin "${plugin.name}" contributors[${index}] must be an object`,
549
+ );
550
+ }
551
+ const { name, source } = entry as { name?: unknown; source?: unknown };
552
+ if (typeof name !== "string" || !CONTRIBUTOR_NAME_PATTERN.test(name)) {
553
+ throw new Error(
554
+ `ttsc: plugin "${plugin.name}" contributors[${index}].name must match /^[a-z][a-z0-9_]*$/; ` +
555
+ `got ${JSON.stringify(name)}`,
556
+ );
557
+ }
558
+ if (seen.has(name)) {
559
+ throw new Error(
560
+ `ttsc: plugin "${plugin.name}" contributors[${index}] duplicate name ${JSON.stringify(name)}`,
561
+ );
562
+ }
563
+ seen.add(name);
564
+ if (typeof source !== "string" || source.length === 0) {
565
+ throw new Error(
566
+ `ttsc: plugin "${plugin.name}" contributors[${index}].source must be a non-empty string`,
567
+ );
568
+ }
569
+ if (!path.isAbsolute(source)) {
570
+ throw new Error(
571
+ `ttsc: plugin "${plugin.name}" contributors[${index}].source must be an absolute path; ` +
572
+ `got ${JSON.stringify(source)}`,
573
+ );
574
+ }
575
+ if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) {
576
+ throw new Error(
577
+ `ttsc: plugin "${plugin.name}" contributors[${index}].source must be an existing directory: ${source}`,
578
+ );
579
+ }
580
+ // Pre-flight check that the directory actually carries a buildable
581
+ // contributor package. Without this, an accidentally-empty directory
582
+ // (or a directory containing only `_test.go` files, which `go build`
583
+ // silently skips) reaches the synthesized blank-import step and Go's
584
+ // compile error surfaces with a scratch-tempdir path that doesn't
585
+ // name the contributor entry. Catching it here lets us name the
586
+ // entry the user actually authored.
587
+ if (!hasBuildableGoSource(source)) {
588
+ throw new Error(
589
+ `ttsc: plugin "${plugin.name}" contributors[${index}].source must contain at least one non-test ".go" file: ${source}`,
590
+ );
591
+ }
592
+ out.push({ name, source: resolveRealPath(source) });
593
+ }
594
+ return out;
595
+ }
596
+
514
597
  function isPluginStage(value: string): value is TtscPluginStage {
515
598
  return value === "transform" || value === "check";
516
599
  }
517
600
 
601
+ function hasBuildableGoSource(dir: string): boolean {
602
+ // `go build` consumes `.go` files but silently ignores `_test.go`. A
603
+ // contributor whose source dir holds only test files would compile to
604
+ // an empty package and surface as an opaque scratch-tempdir error;
605
+ // require at least one production `.go` file so the validator can
606
+ // name the contributor entry instead.
607
+ let entries: string[];
608
+ try {
609
+ entries = fs.readdirSync(dir);
610
+ } catch {
611
+ return false;
612
+ }
613
+ return entries.some(
614
+ (name) => name.endsWith(".go") && !name.endsWith("_test.go"),
615
+ );
616
+ }
617
+
518
618
  function resolvePluginRequest(specifier: string, projectRoot: string): string {
519
619
  if (path.isAbsolute(specifier)) {
520
620
  return resolveRealPath(specifier);
@@ -88,7 +88,7 @@ export interface ITtscCompilerContext {
88
88
  * directly installed package markers.
89
89
  * - `false`: ignore project config plugins and package markers for this
90
90
  * compiler instance.
91
- * - array: use these plugin entries instead of project config entries and
91
+ * - Array: use these plugin entries instead of project config entries and
92
92
  * package markers.
93
93
  *
94
94
  * Plugin entries are resolved once per operation from this instance context.
@@ -11,11 +11,11 @@
11
11
  */
12
12
  export interface ITtscCompilerDiagnostic {
13
13
  /**
14
- * The filename where the diagnostic originated, or null if not
15
- * file-specific.
14
+ * The filename where the diagnostic originated, or null if not file-specific.
16
15
  *
17
16
  * Global diagnostics such as invalid compiler options or host-level failures
18
- * are reported with `null` because they are not tied to a single source file.
17
+ * are reported with `null` because they are not tied to a single source
18
+ * file.
19
19
  */
20
20
  file: string | null;
21
21
 
@@ -3,8 +3,8 @@ import type { ITtscCompilerDiagnostic } from "./ITtscCompilerDiagnostic";
3
3
  /**
4
4
  * Result of a ttsc TypeScript-Go compilation operation.
5
5
  *
6
- * Represents the possible outcomes of {@link TtscCompiler.compile}, which can
7
- * be either a successful compilation, a compilation that completed with
6
+ * Represents the possible outcomes of {@link TtscCompiler.compile}, which can be
7
+ * either a successful compilation, a compilation that completed with
8
8
  * diagnostics, or an unexpected host error during the compilation process.
9
9
  *
10
10
  * This type follows the legacy `embed-typescript` result model, but stores all
@@ -25,18 +25,14 @@ export namespace ITtscCompilerResult {
25
25
  * Represents a successful ttsc compilation result.
26
26
  *
27
27
  * This interface is returned when TypeScript-Go completes without
28
- * diagnostics, containing every text output captured from the native
29
- * compiler host's `WriteFile` callback.
28
+ * diagnostics, containing every text output captured from the native compiler
29
+ * host's `WriteFile` callback.
30
30
  */
31
31
  export interface ISuccess {
32
- /**
33
- * Indicates that the compilation was successful.
34
- */
32
+ /** Indicates that the compilation was successful. */
35
33
  type: "success";
36
34
 
37
- /**
38
- * Non-fatal diagnostics reported during compilation.
39
- */
35
+ /** Non-fatal diagnostics reported during compilation. */
40
36
  diagnostics?: ITtscCompilerDiagnostic[];
41
37
 
42
38
  /**
@@ -59,14 +55,10 @@ export namespace ITtscCompilerResult {
59
55
  * diagnostics.
60
56
  */
61
57
  export interface IFailure {
62
- /**
63
- * Indicates that compilation completed with diagnostics.
64
- */
58
+ /** Indicates that compilation completed with diagnostics. */
65
59
  type: "failure";
66
60
 
67
- /**
68
- * Array of diagnostic messages describing the compilation issues.
69
- */
61
+ /** Array of diagnostic messages describing the compilation issues. */
70
62
  diagnostics: ITtscCompilerDiagnostic[];
71
63
 
72
64
  /**
@@ -86,14 +78,10 @@ export namespace ITtscCompilerResult {
86
78
  * {@link IFailure}; this variant is reserved for host-level exceptions.
87
79
  */
88
80
  export interface IException {
89
- /**
90
- * Indicates that an unexpected host error occurred.
91
- */
81
+ /** Indicates that an unexpected host error occurred. */
92
82
  type: "exception";
93
83
 
94
- /**
95
- * The error that was thrown while preparing or running the compiler host.
96
- */
84
+ /** The error that was thrown while preparing or running the compiler host. */
97
85
  error: unknown;
98
86
  }
99
87
  }
@@ -4,8 +4,8 @@ import type { ITtscCompilerDiagnostic } from "./ITtscCompilerDiagnostic";
4
4
  * Result of a TypeScript source-to-source transformation operation.
5
5
  *
6
6
  * This mirrors `embed-typescript`'s `IEmbedTypeScriptTransformation` model.
7
- * Unlike {@link ITtscCompilerResult}, this contract is not an emit contract:
8
- * the `typescript` map must contain TypeScript source text, not generated
7
+ * Unlike {@link ITtscCompilerResult}, this contract is not an emit contract: the
8
+ * `typescript` map must contain TypeScript source text, not generated
9
9
  * JavaScript, declaration files, or source maps.
10
10
  */
11
11
  export type ITtscCompilerTransformation =
@@ -1,3 +1,4 @@
1
+ import type { ITtscPluginContributor } from "./ITtscPluginContributor";
1
2
  import type { TtscPluginStage } from "./TtscPluginStage";
2
3
 
3
4
  /**
@@ -28,7 +29,7 @@ export interface ITtscPlugin {
28
29
  /**
29
30
  * Go command package directory, or a `go.mod` file, that ttsc lazily builds.
30
31
  *
31
- * ttsc accepts source only. It does not accept a prebuilt binary path: the
32
+ * Ttsc accepts source only. It does not accept a prebuilt binary path: the
32
33
  * package-local Go compiler builds this source into the ttsc plugin cache on
33
34
  * demand.
34
35
  *
@@ -60,19 +61,44 @@ export interface ITtscPlugin {
60
61
  * Pipeline stage implemented by the sidecar.
61
62
  *
62
63
  * Omit this field for normal compiler-transform plugins. The only explicit
63
- * non-transform stage is `"check"`.
64
+ * non-transform stage is `"check"`. Check-stage plugins receive `check`
65
+ * during normal builds and may implement `fix` for `ttsc fix` and `format`
66
+ * for `ttsc format`.
64
67
  *
65
68
  * @default "transform"
66
69
  */
67
70
  stage?: TtscPluginStage;
68
- }
69
71
 
70
- export function defineTtscPlugin<T extends ITtscPlugin>(plugin: T): T;
71
- export function defineTtscPlugin<TContext, T extends ITtscPlugin>(
72
- factory: (context: TContext) => T,
73
- ): (context: TContext) => T;
74
- export function defineTtscPlugin(
75
- value: ITtscPlugin | ((context: unknown) => ITtscPlugin),
76
- ): ITtscPlugin | ((context: unknown) => ITtscPlugin) {
77
- return value;
72
+ /**
73
+ * Additional Go source packages to statically link into this plugin's binary
74
+ * at build time ("plugin-within-plugin" composition).
75
+ *
76
+ * Each contributor's Go source directory is copied into the scratch build
77
+ * tree as a sub-package of this plugin's module and reached by a synthesized
78
+ * blank import. The contributor's `init()` runs before the host binary's
79
+ * `main`, registering whatever state the host expects to find at startup
80
+ * (e.g. lint rules through `github.com/samchon/ttsc/packages/lint/rule`).
81
+ *
82
+ * Differs from `composes`:
83
+ *
84
+ * - `composes` is horizontal — many plugin entries dispatch to one binary by
85
+ * name. Each entry is still a top-level `compilerOptions.plugins[]` citizen
86
+ * with its own lifecycle slot.
87
+ * - `contributors` is vertical — one binary statically links additional Go
88
+ * sources that never appear as top-level plugin entries. The contributing
89
+ * npm packages are discovered through the host plugin's own config file
90
+ * (e.g. `lint.config.ts` for `@ttsc/lint`).
91
+ *
92
+ * Constraints:
93
+ *
94
+ * - Contributors ship Go source as a package (no `go.mod`); the host plugin's
95
+ * module supplies every transitive Go dependency. This is also a
96
+ * supply-chain feature — contributors cannot pull in arbitrary Go modules
97
+ * at build time.
98
+ * - Contributor source paths must be absolute (the host plugin's JS factory
99
+ * typically resolves them through `require.resolve`).
100
+ * - Contributor names are used as the sub-package import suffix and must be
101
+ * unique within a single plugin build.
102
+ */
103
+ contributors?: ITtscPluginContributor[];
78
104
  }