ttsc 0.4.0-dev.20260424 → 0.4.0-dev.20260425

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.
@@ -0,0 +1,487 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ import {
6
+ build,
7
+ check,
8
+ type CommonOptions,
9
+ transform,
10
+ } from "../api";
11
+ import {
12
+ defaultCacheDirectory,
13
+ resolveProjectConfig,
14
+ resolveProjectRoot,
15
+ } from "../project";
16
+ import {
17
+ resolveBinary,
18
+ } from "../platform";
19
+
20
+ export interface RegisterOptions extends CommonOptions {
21
+ cacheDir?: string;
22
+ project?: string;
23
+ extensions?: readonly string[];
24
+ }
25
+
26
+ export interface PreparedExecution {
27
+ emitDir: string;
28
+ entryFile: string;
29
+ moduleKind: "cjs" | "esm";
30
+ }
31
+
32
+ type RequireExtension = (module: NodeJS.Module, filename: string) => void;
33
+ type CompilableModule = NodeJS.Module & {
34
+ _compile(code: string, filename: string): void;
35
+ };
36
+
37
+ interface ProjectContext {
38
+ cacheDir: string;
39
+ cacheSalt: string;
40
+ compiledText: Map<string, string>;
41
+ emitDir: string;
42
+ emittedFiles: string[] | null;
43
+ diagnosticsChecked: boolean;
44
+ entryMap: Map<string, string>;
45
+ root: string;
46
+ tsconfig: string;
47
+ }
48
+
49
+ const DEFAULT_EXTENSIONS: readonly string[] = Object.freeze([
50
+ ".ts",
51
+ ".tsx",
52
+ ".cts",
53
+ ".mts",
54
+ ]);
55
+
56
+ const PROCESS_CACHE_KEY = String(process.pid);
57
+ const SINGLE_FILE_CACHE_VERSION = "v4";
58
+
59
+ export function register(options: RegisterOptions = {}): () => void {
60
+ const cwd = path.resolve(options.cwd ?? process.cwd());
61
+ const extensions = [...(options.extensions ?? DEFAULT_EXTENSIONS)];
62
+ const contextCache = new Map<string, ProjectContext>();
63
+ const previous = new Map<string, RequireExtension | undefined>();
64
+
65
+ const getContext = (filename: string): ProjectContext => {
66
+ if (options.project) {
67
+ const key = path.resolve(cwd, options.project);
68
+ const cached = contextCache.get(key);
69
+ if (cached) return cached;
70
+ const tsconfig = resolveProjectConfig({ cwd, tsconfig: key });
71
+ const root = resolveProjectRoot({ cwd, tsconfig });
72
+ const cacheDir = options.cacheDir ?? defaultCacheDirectory(root, "ttsx");
73
+ const created = {
74
+ tsconfig,
75
+ root,
76
+ cacheDir,
77
+ cacheSalt: createCompilerCacheSalt(options),
78
+ compiledText: new Map<string, string>(),
79
+ diagnosticsChecked: false,
80
+ emitDir: path.join(cacheDir, "project", PROCESS_CACHE_KEY),
81
+ emittedFiles: null,
82
+ entryMap: new Map<string, string>(),
83
+ };
84
+ contextCache.set(key, created);
85
+ return created;
86
+ }
87
+
88
+ const tsconfig = resolveProjectConfig({ cwd, file: filename });
89
+ const cached = contextCache.get(tsconfig);
90
+ if (cached) return cached;
91
+ const root = resolveProjectRoot({ cwd, tsconfig });
92
+ const cacheDir = options.cacheDir ?? defaultCacheDirectory(root, "ttsx");
93
+ const created = {
94
+ tsconfig,
95
+ root,
96
+ cacheDir,
97
+ cacheSalt: createCompilerCacheSalt(options),
98
+ compiledText: new Map<string, string>(),
99
+ diagnosticsChecked: false,
100
+ emitDir: path.join(cacheDir, "project", PROCESS_CACHE_KEY),
101
+ emittedFiles: null,
102
+ entryMap: new Map<string, string>(),
103
+ };
104
+ contextCache.set(tsconfig, created);
105
+ return created;
106
+ };
107
+
108
+ const compile = (filename: string): string => {
109
+ const context = getContext(filename);
110
+ ensureProjectDiagnostics(context, options);
111
+ const output = readCompiledOutput(context, filename, options);
112
+ if (looksLikeESM(output)) {
113
+ throw new Error(
114
+ `ttsx: ESM output is not yet supported by the in-process CJS runner (${filename}).`,
115
+ );
116
+ }
117
+ return output;
118
+ };
119
+
120
+ for (const extension of extensions) {
121
+ previous.set(extension, require.extensions[extension]);
122
+ require.extensions[extension] = (module: NodeJS.Module, filename: string) => {
123
+ const compiled = compile(filename);
124
+ (module as CompilableModule)._compile(compiled, filename);
125
+ };
126
+ }
127
+
128
+ return () => {
129
+ for (const extension of extensions) {
130
+ const handler = previous.get(extension);
131
+ if (handler) {
132
+ require.extensions[extension] = handler;
133
+ } else {
134
+ delete require.extensions[extension];
135
+ }
136
+ }
137
+ };
138
+ }
139
+
140
+ export function prepareExecution(
141
+ entryFile: string,
142
+ options: RegisterOptions = {},
143
+ ): PreparedExecution {
144
+ const cwd = path.resolve(options.cwd ?? process.cwd());
145
+ const context = resolveProjectContext(cwd, entryFile, options);
146
+ ensureProjectDiagnostics(context, options);
147
+ const entryOutput = transformSingleFile(context, entryFile, options);
148
+ if (!looksLikeESM(entryOutput)) {
149
+ return {
150
+ emitDir: context.emitDir,
151
+ entryFile,
152
+ moduleKind: "cjs",
153
+ };
154
+ }
155
+ ensureProjectBuild(context, options);
156
+ const resolvedEntry = resolveEmittedFile(context, entryFile);
157
+ if (resolvedEntry === null) {
158
+ throw new Error(`ttsx: emitted entry not found for ${entryFile}`);
159
+ }
160
+ const output = fs.readFileSync(resolvedEntry, "utf8");
161
+ return {
162
+ emitDir: context.emitDir,
163
+ entryFile: resolvedEntry,
164
+ moduleKind: looksLikeESM(output) ? "esm" : "cjs",
165
+ };
166
+ }
167
+
168
+ function ensureProjectBuild(context: ProjectContext, options: RegisterOptions): void {
169
+ if (context.emittedFiles !== null) return;
170
+
171
+ fs.mkdirSync(context.cacheDir, { recursive: true });
172
+ fs.rmSync(context.emitDir, { recursive: true, force: true });
173
+ const result = build({
174
+ binary: options.binary,
175
+ cwd: context.root,
176
+ env: options.env,
177
+ emit: true,
178
+ outDir: context.emitDir,
179
+ quiet: true,
180
+ tsconfig: context.tsconfig,
181
+ });
182
+ if (result.status === 0) {
183
+ context.emittedFiles = listEmittedFiles(context.emitDir);
184
+ return;
185
+ }
186
+
187
+ fs.rmSync(context.emitDir, { recursive: true, force: true });
188
+ const detail = [
189
+ `ttsx: native project build failed for ${context.tsconfig}`,
190
+ result.stderr || result.stdout,
191
+ ]
192
+ .filter((line) => line.trim().length !== 0)
193
+ .join("\n");
194
+ throw new Error(detail);
195
+ }
196
+
197
+ function readCompiledOutput(
198
+ context: ProjectContext,
199
+ filename: string,
200
+ options: RegisterOptions,
201
+ ): string {
202
+ return transformSingleFile(context, filename, options);
203
+ }
204
+
205
+ function resolveProjectContext(
206
+ cwd: string,
207
+ filename: string,
208
+ options: RegisterOptions,
209
+ ): ProjectContext {
210
+ if (options.project) {
211
+ const tsconfig = resolveProjectConfig({
212
+ cwd,
213
+ tsconfig: path.resolve(cwd, options.project),
214
+ });
215
+ return createProjectContext(cwd, tsconfig, options);
216
+ }
217
+ const tsconfig = resolveProjectConfig({ cwd, file: filename });
218
+ return createProjectContext(cwd, tsconfig, options);
219
+ }
220
+
221
+ function createProjectContext(
222
+ cwd: string,
223
+ tsconfig: string,
224
+ options: RegisterOptions,
225
+ ): ProjectContext {
226
+ const root = resolveProjectRoot({ cwd, tsconfig });
227
+ const cacheDir = options.cacheDir ?? defaultCacheDirectory(root, "ttsx");
228
+ return {
229
+ tsconfig,
230
+ root,
231
+ cacheDir,
232
+ cacheSalt: createCompilerCacheSalt(options),
233
+ compiledText: new Map<string, string>(),
234
+ diagnosticsChecked: false,
235
+ emitDir: path.join(cacheDir, "project", PROCESS_CACHE_KEY),
236
+ emittedFiles: null,
237
+ entryMap: new Map<string, string>(),
238
+ };
239
+ }
240
+
241
+ function ensureProjectDiagnostics(
242
+ context: ProjectContext,
243
+ options: RegisterOptions,
244
+ ): void {
245
+ if (context.diagnosticsChecked) {
246
+ return;
247
+ }
248
+ const result = check({
249
+ binary: options.binary,
250
+ cwd: context.root,
251
+ env: options.env,
252
+ plugins: options.plugins,
253
+ quiet: true,
254
+ rewriteMode: options.rewriteMode,
255
+ tsconfig: context.tsconfig,
256
+ });
257
+ if (result.status !== 0) {
258
+ const detail = [result.stderr.trim(), result.stdout.trim()]
259
+ .filter(Boolean)
260
+ .join("\n");
261
+ throw new Error(
262
+ `ttsx: project check failed for ${context.tsconfig}` +
263
+ (detail ? `\n${detail}` : ""),
264
+ );
265
+ }
266
+ context.diagnosticsChecked = true;
267
+ }
268
+
269
+ function transformSingleFile(
270
+ context: ProjectContext,
271
+ filename: string,
272
+ options: RegisterOptions,
273
+ ): string {
274
+ const normalized = path.resolve(filename);
275
+ const cached = context.compiledText.get(normalized);
276
+ if (cached !== undefined) return cached;
277
+
278
+ const cacheFile = singleFileCachePath(context, normalized);
279
+ const sourceStat = safeStat(normalized);
280
+ const cacheStat = safeStat(cacheFile);
281
+ if (
282
+ sourceStat &&
283
+ cacheStat &&
284
+ cacheStat.isFile() &&
285
+ cacheStat.mtimeMs >= sourceStat.mtimeMs
286
+ ) {
287
+ const output = fs.readFileSync(cacheFile, "utf8");
288
+ context.compiledText.set(normalized, output);
289
+ return output;
290
+ }
291
+
292
+ const output = transform({
293
+ binary: options.binary,
294
+ cwd: context.root,
295
+ env: options.env,
296
+ file: normalized,
297
+ rewriteMode: options.rewriteMode,
298
+ tsconfig: context.tsconfig,
299
+ });
300
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
301
+ fs.writeFileSync(cacheFile, output, "utf8");
302
+ context.compiledText.set(normalized, output);
303
+ return output;
304
+ }
305
+
306
+ function singleFileCachePath(context: ProjectContext, filename: string): string {
307
+ const digest = createHash("sha1")
308
+ .update(context.cacheSalt)
309
+ .update("\0")
310
+ .update(filename)
311
+ .digest("hex");
312
+ return path.join(
313
+ context.cacheDir,
314
+ "single",
315
+ SINGLE_FILE_CACHE_VERSION,
316
+ `${digest}.js`,
317
+ );
318
+ }
319
+
320
+ function createCompilerCacheSalt(options: RegisterOptions): string {
321
+ const parts = [SINGLE_FILE_CACHE_VERSION];
322
+ const binary = resolveBinary(options);
323
+ if (binary) {
324
+ parts.push(fileSignature(binary));
325
+ for (const location of workspaceCompilerLocations(binary)) {
326
+ const signature = treeSignature(location);
327
+ if (signature) {
328
+ parts.push(signature);
329
+ }
330
+ }
331
+ }
332
+ return parts.join("|");
333
+ }
334
+
335
+ function workspaceCompilerLocations(binary: string): string[] {
336
+ const resolved = path.resolve(binary);
337
+ const packageRoot = path.resolve(path.dirname(resolved), "..");
338
+ const candidates = [
339
+ path.resolve(packageRoot, "cmd"),
340
+ path.resolve(packageRoot, "src"),
341
+ ];
342
+ return candidates.filter((location) => fs.existsSync(location));
343
+ }
344
+
345
+ function fileSignature(file: string): string {
346
+ const stat = safeStat(file);
347
+ if (!stat) return `missing:${path.resolve(file)}`;
348
+ return `file:${path.resolve(file)}:${stat.size}:${stat.mtimeMs}`;
349
+ }
350
+
351
+ function treeSignature(root: string): string | null {
352
+ if (!fs.existsSync(root)) return null;
353
+ let latest = 0;
354
+ let count = 0;
355
+ const stack = [root];
356
+ while (stack.length !== 0) {
357
+ const current = stack.pop()!;
358
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
359
+ const next = path.join(current, entry.name);
360
+ if (entry.isDirectory()) {
361
+ stack.push(next);
362
+ continue;
363
+ }
364
+ if (!entry.isFile()) continue;
365
+ if (!/\.(?:go|[cm]?js|ts)$/.test(entry.name)) continue;
366
+ const stat = safeStat(next);
367
+ if (!stat) continue;
368
+ latest = Math.max(latest, stat.mtimeMs);
369
+ count += 1;
370
+ }
371
+ }
372
+ return `tree:${path.resolve(root)}:${count}:${latest}`;
373
+ }
374
+
375
+ function safeStat(file: string): fs.Stats | null {
376
+ try {
377
+ return fs.statSync(file);
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ function resolveEmittedFile(context: ProjectContext, filename: string): string | null {
384
+ const normalized = path.resolve(filename);
385
+ const cached = context.entryMap.get(normalized);
386
+ if (cached && fs.existsSync(cached)) return cached;
387
+
388
+ const exact = resolveExactEmittedFile(context, normalized);
389
+ if (exact && fs.existsSync(exact)) {
390
+ context.entryMap.set(normalized, exact);
391
+ return exact;
392
+ }
393
+
394
+ const files = context.emittedFiles ?? [];
395
+ let best: string | null = null;
396
+ let bestScore = 0;
397
+ for (const file of files) {
398
+ const score = sharedSourceStemSegments(file, normalized);
399
+ if (score > bestScore) {
400
+ best = file;
401
+ bestScore = score;
402
+ }
403
+ }
404
+ if (!best || !fs.existsSync(best)) {
405
+ return null;
406
+ }
407
+ context.entryMap.set(normalized, best);
408
+ return best;
409
+ }
410
+
411
+ function resolveExactEmittedFile(
412
+ context: ProjectContext,
413
+ filename: string,
414
+ ): string | null {
415
+ const relative = path.relative(context.root, filename);
416
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
417
+ return null;
418
+ }
419
+ return path.resolve(
420
+ context.emitDir,
421
+ relative.slice(0, relative.length - path.extname(relative).length) +
422
+ emittedJavaScriptExtension(filename),
423
+ );
424
+ }
425
+
426
+ function listEmittedFiles(root: string): string[] {
427
+ if (!fs.existsSync(root)) return [];
428
+ const output: string[] = [];
429
+ const stack = [root];
430
+ while (stack.length !== 0) {
431
+ const current = stack.pop()!;
432
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
433
+ const next = path.join(current, entry.name);
434
+ if (entry.isDirectory()) {
435
+ stack.push(next);
436
+ } else if (entry.isFile() && isJavaScriptOutput(next)) {
437
+ output.push(path.resolve(next));
438
+ }
439
+ }
440
+ }
441
+ return output;
442
+ }
443
+
444
+ function sharedSourceStemSegments(outPath: string, srcPath: string): number {
445
+ const trim = (location: string): string[] => {
446
+ const normalized = location.split(path.sep).join("/");
447
+ return normalized
448
+ .slice(0, normalized.length - path.extname(normalized).length)
449
+ .split("/");
450
+ };
451
+ const a = trim(outPath);
452
+ const b = trim(srcPath);
453
+ const count = Math.min(a.length, b.length);
454
+ let shared = 0;
455
+ for (let i = 1; i <= count; i += 1) {
456
+ if (a[a.length - i] !== b[b.length - i]) break;
457
+ shared += 1;
458
+ }
459
+ return shared;
460
+ }
461
+
462
+ function looksLikeESM(output: string): boolean {
463
+ if (
464
+ /\bObject\.defineProperty\(exports\b/.test(output) ||
465
+ /\bmodule\.exports\b/.test(output) ||
466
+ /\brequire\(/.test(output) ||
467
+ /\bexports\./.test(output)
468
+ ) {
469
+ return false;
470
+ }
471
+ return /^\s*(import|export)\s/m.test(output);
472
+ }
473
+
474
+ function emittedJavaScriptExtension(filename: string): string {
475
+ switch (path.extname(filename).toLowerCase()) {
476
+ case ".mts":
477
+ return ".mjs";
478
+ case ".cts":
479
+ return ".cjs";
480
+ default:
481
+ return ".js";
482
+ }
483
+ }
484
+
485
+ function isJavaScriptOutput(filename: string): boolean {
486
+ return /\.(?:[cm]?js)$/i.test(filename);
487
+ }
@@ -6,22 +6,32 @@ const test = require("node:test");
6
6
  const workspaceRoot = path.resolve(__dirname, "../../..");
7
7
 
8
8
  test("package metadata separates monorepo source exports from publish exports", () => {
9
- for (const packageName of ["ttsc", "ttsx"]) {
10
- const packageJson = readPackageJson(packageName);
11
- assert.match(packageJson.main, /^src\/.+\.ts$/);
12
- assert.match(packageJson.types, /^src\/.+\.ts$/);
13
- assert.equal(packageJson.exports["."], "./src/index.ts");
14
- assert.equal(packageJson.publishConfig.main, "lib/index.js");
15
- assert.equal(packageJson.publishConfig.types, "lib/index.d.ts");
16
- assert.equal(
17
- packageJson.publishConfig.exports["."].types,
18
- "./lib/index.d.ts",
19
- );
20
- assert.equal(
21
- packageJson.publishConfig.exports["."].default,
22
- "./lib/index.js",
23
- );
24
- }
9
+ const packageJson = readPackageJson("ttsc");
10
+ assert.match(packageJson.main, /^src\/.+\.ts$/);
11
+ assert.match(packageJson.types, /^src\/.+\.ts$/);
12
+ assert.equal(packageJson.exports["."].types, "./src/index.ts");
13
+ assert.equal(packageJson.exports["."].import, "./src/index.ts");
14
+ assert.equal(packageJson.exports["."].require, "./lib/index.js");
15
+ assert.equal(packageJson.exports["."].default, "./src/index.ts");
16
+ assert.equal(packageJson.publishConfig.main, "lib/index.js");
17
+ assert.equal(packageJson.publishConfig.types, "lib/index.d.ts");
18
+ assert.equal(
19
+ packageJson.publishConfig.exports["."].types,
20
+ "./lib/index.d.ts",
21
+ );
22
+ assert.equal(
23
+ packageJson.publishConfig.exports["."].default,
24
+ "./lib/index.js",
25
+ );
26
+ });
27
+
28
+ test("ttsc package owns both compiler and runtime commands", () => {
29
+ const packageJson = readPackageJson("ttsc");
30
+ assert.deepEqual(packageJson.bin, {
31
+ ttsc: "src/launcher/ttsc.js",
32
+ ttsx: "src/launcher/ttsx.js",
33
+ });
34
+ assert.deepEqual(packageJson.publishConfig.bin, packageJson.bin);
25
35
  });
26
36
 
27
37
  test("ttsc exposes plugin helpers through the root package only", () => {
@@ -86,9 +96,7 @@ test("published package file lists keep TypeScript and Go sources", () => {
86
96
  }
87
97
  assert.equal(ttsc.files.includes("tsconfig.json"), false);
88
98
 
89
- const ttsx = readPackageJson("ttsx");
90
- assert.equal(ttsx.files.includes("src"), true, "ttsx files must include src");
91
- assert.equal(ttsx.files.includes("tsconfig.json"), false);
99
+ assert.equal(fs.existsSync(path.join(workspaceRoot, "packages", "ttsx")), false);
92
100
  });
93
101
 
94
102
  test("platform packages expose os cpu constrained native binaries", () => {