ttsc 0.18.3 → 0.19.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 (173) hide show
  1. package/README.md +5 -6
  2. package/cmd/graphbench/main.go +1 -1
  3. package/cmd/platform/main.go +1 -48
  4. package/cmd/ttsc/build_branches_test.go +5 -1
  5. package/cmd/ttsc/main.go +0 -50
  6. package/cmd/ttsc/main_alias_branches_test.go +6 -12
  7. package/cmd/ttscgraph/dump.go +13 -3
  8. package/cmd/ttscgraph/main.go +18 -12
  9. package/cmd/ttscgraph/run_help_version_and_bad_flags_exit_codes_test.go +23 -5
  10. package/cmd/ttscgraph/serve.go +863 -0
  11. package/cmd/ttscgraph/serve_protocol_rejects_invalid_request_line_test.go +43 -0
  12. package/cmd/ttscgraph/serve_protocol_retries_session_creation_test.go +83 -0
  13. package/cmd/ttscgraph/serve_protocol_reuses_session_test.go +35 -0
  14. package/cmd/ttscgraph/serve_session_applies_source_edit_incrementally_test.go +55 -0
  15. package/cmd/ttscgraph/serve_session_fails_closed_on_deleted_config_test.go +68 -0
  16. package/cmd/ttscgraph/serve_session_fails_closed_on_invalid_config_test.go +50 -0
  17. package/cmd/ttscgraph/serve_session_keeps_missing_literal_root_unchanged_test.go +46 -0
  18. package/cmd/ttscgraph/serve_session_rebuilds_import_graph_change_test.go +60 -0
  19. package/cmd/ttscgraph/serve_session_reloads_created_literal_root_test.go +44 -0
  20. package/cmd/ttscgraph/serve_session_reloads_extended_config_change_test.go +45 -0
  21. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_conditional_export_test.go +60 -0
  22. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_config_types_test.go +44 -0
  23. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_excluded_import_test.go +62 -0
  24. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_export_test.go +46 -0
  25. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_package_import_test.go +46 -0
  26. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_path_alias_test.go +47 -0
  27. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_reference_path_test.go +42 -0
  28. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_root_dirs_target_test.go +46 -0
  29. package/cmd/ttscgraph/serve_session_reloads_newly_resolved_type_reference_test.go +42 -0
  30. package/cmd/ttscgraph/serve_session_reloads_root_file_set_test.go +52 -0
  31. package/cmd/ttscgraph/serve_session_reloads_source_deleted_during_load_test.go +60 -0
  32. package/cmd/ttscgraph/serve_session_reloads_transitive_project_reference_test.go +55 -0
  33. package/cmd/ttscgraph/serve_session_reloads_when_program_plugin_linked_test.go +53 -0
  34. package/cmd/ttscgraph/serve_session_reuses_unchanged_preamble_source_test.go +45 -0
  35. package/cmd/ttscgraph/serve_session_reuses_unchanged_snapshot_test.go +52 -0
  36. package/cmd/ttscgraph/serve_session_revisits_source_edited_during_load_test.go +61 -0
  37. package/cmd/ttscgraph/serve_session_tolerates_os_invalid_resolution_candidate_test.go +56 -0
  38. package/cmd/ttscgraph/serve_session_tracks_all_module_specifier_forms_test.go +41 -0
  39. package/cmd/ttscgraph/serve_session_tracks_diamond_project_references_once_test.go +61 -0
  40. package/cmd/ttscserver/main.go +5 -3
  41. package/driver/lsp.go +13 -1
  42. package/driver/plugins.go +56 -5
  43. package/driver/program.go +25 -10
  44. package/driver/rewrite.go +381 -21
  45. package/driver/session.go +11 -3
  46. package/go.mod +1 -0
  47. package/internal/graph/build.go +297 -3
  48. package/internal/graph/build_records_a_node_per_top_level_declaration_test.go +16 -2
  49. package/internal/graph/diagnostics.go +5 -4
  50. package/internal/graph/dump.go +60 -4
  51. package/internal/graph/dump_is_a_function_of_the_source_test.go +71 -0
  52. package/internal/graph/dump_serializes_the_full_graph_test.go +16 -4
  53. package/internal/graph/edges.go +122 -11
  54. package/internal/graph/exports.go +74 -17
  55. package/internal/graph/graph.go +33 -1
  56. package/internal/graph/node_identity_is_position_invariant_test.go +4 -2
  57. package/internal/graph/resolve.go +24 -0
  58. package/internal/graphsymbols/provider.go +50 -14
  59. package/internal/lspserver/lsp_native_plugin_source.go +77 -23
  60. package/internal/lspserver/lsp_plugin.go +19 -2
  61. package/internal/lspserver/lsp_proxy.go +244 -52
  62. package/internal/lspserver/lsp_server.go +35 -27
  63. package/internal/lspserver/lsp_symbols.go +25 -16
  64. package/lib/TtscCompiler.d.ts +13 -2
  65. package/lib/TtscCompiler.js +30 -20
  66. package/lib/TtscCompiler.js.map +1 -1
  67. package/lib/TtscService.js +9 -7
  68. package/lib/TtscService.js.map +1 -1
  69. package/lib/compiler/internal/compileProjectInMemory.js +15 -1
  70. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  71. package/lib/compiler/internal/project/createNativeProjectContextArgs.d.ts +3 -0
  72. package/lib/compiler/internal/project/createNativeProjectContextArgs.js +14 -0
  73. package/lib/compiler/internal/project/createNativeProjectContextArgs.js.map +1 -0
  74. package/lib/compiler/internal/project/readProjectConfig.js +53 -4
  75. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  76. package/lib/compiler/internal/project/resolveProjectConfig.d.ts +9 -2
  77. package/lib/compiler/internal/project/resolveProjectConfig.js +45 -16
  78. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  79. package/lib/compiler/internal/residentTransformProcess.d.ts +16 -5
  80. package/lib/compiler/internal/residentTransformProcess.js +68 -20
  81. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  82. package/lib/compiler/internal/runBuild.d.ts +18 -0
  83. package/lib/compiler/internal/runBuild.js +220 -24
  84. package/lib/compiler/internal/runBuild.js.map +1 -1
  85. package/lib/compiler/internal/runSingleFileEmit.js +7 -4
  86. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  87. package/lib/compiler/internal/sharedHostHelpers.d.ts +16 -0
  88. package/lib/compiler/internal/sharedHostHelpers.js +23 -0
  89. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  90. package/lib/compiler/internal/startResidentTransform.js +18 -1
  91. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  92. package/lib/compiler/internal/transformProjectInMemory.js +34 -8
  93. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  94. package/lib/flags/parser.d.ts +17 -0
  95. package/lib/flags/parser.js +11 -1
  96. package/lib/flags/parser.js.map +1 -1
  97. package/lib/flags/schema.js +7 -0
  98. package/lib/flags/schema.js.map +1 -1
  99. package/lib/launcher/internal/prepareExecution.js +21 -2
  100. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  101. package/lib/launcher/internal/runTtsc.js +13 -53
  102. package/lib/launcher/internal/runTtsc.js.map +1 -1
  103. package/lib/launcher/internal/runTtscserver.js +11 -0
  104. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  105. package/lib/launcher/internal/runTtsx.js +27 -11
  106. package/lib/launcher/internal/runTtsx.js.map +1 -1
  107. package/lib/launcher/internal/runtimeHooks.d.ts +81 -0
  108. package/lib/launcher/internal/runtimeHooks.js +632 -43
  109. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  110. package/lib/launcher/internal/servedSourceMap.d.ts +25 -0
  111. package/lib/launcher/internal/servedSourceMap.js +164 -0
  112. package/lib/launcher/internal/servedSourceMap.js.map +1 -0
  113. package/lib/plugin/internal/buildSourcePlugin.d.ts +146 -2
  114. package/lib/plugin/internal/buildSourcePlugin.js +651 -113
  115. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  116. package/lib/plugin/internal/loadProjectPlugins.d.ts +7 -0
  117. package/lib/plugin/internal/loadProjectPlugins.js +33 -10
  118. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  119. package/lib/structures/ITtscCompilerContext.d.ts +16 -2
  120. package/lib/structures/ITtscCompilerResult.d.ts +8 -5
  121. package/lib/structures/ITtscPluginCapabilities.d.ts +8 -0
  122. package/lib/structures/ITtscPluginFactoryContext.d.ts +14 -0
  123. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +3 -0
  124. package/lib/structures/internal/ITtscProjectIdentity.d.ts +17 -0
  125. package/lib/structures/internal/ITtscProjectIdentity.js +3 -0
  126. package/lib/structures/internal/ITtscProjectIdentity.js.map +1 -0
  127. package/lib/structures/internal/TtscCommonOptions.d.ts +8 -0
  128. package/package.json +11 -8
  129. package/shim/ast/lint.go +10 -0
  130. package/shim/ast/shim.go +44 -0
  131. package/shim/checker/enums_gen.go +19 -0
  132. package/shim/checker/shim.go +130 -0
  133. package/shim/diagnosticwriter/lint.go +30 -3
  134. package/shim/printer/shim.go +22 -4
  135. package/shim/scanner/regexp.go +27 -0
  136. package/src/TtscCompiler.ts +29 -22
  137. package/src/TtscService.ts +15 -7
  138. package/src/compiler/internal/compileProjectInMemory.ts +18 -1
  139. package/src/compiler/internal/project/createNativeProjectContextArgs.ts +15 -0
  140. package/src/compiler/internal/project/readProjectConfig.ts +61 -5
  141. package/src/compiler/internal/project/resolveProjectConfig.ts +51 -21
  142. package/src/compiler/internal/residentTransformProcess.ts +95 -21
  143. package/src/compiler/internal/runBuild.ts +313 -32
  144. package/src/compiler/internal/runSingleFileEmit.ts +7 -4
  145. package/src/compiler/internal/sharedHostHelpers.ts +24 -0
  146. package/src/compiler/internal/startResidentTransform.ts +21 -1
  147. package/src/compiler/internal/transformProjectInMemory.ts +49 -6
  148. package/src/flags/parser.ts +28 -1
  149. package/src/flags/schema.ts +8 -0
  150. package/src/launcher/internal/prepareExecution.ts +22 -2
  151. package/src/launcher/internal/runTtsc.ts +13 -61
  152. package/src/launcher/internal/runTtscserver.ts +14 -0
  153. package/src/launcher/internal/runTtsx.ts +28 -13
  154. package/src/launcher/internal/runtimeHooks.ts +771 -53
  155. package/src/launcher/internal/servedSourceMap.ts +183 -0
  156. package/src/plugin/internal/buildSourcePlugin.ts +859 -122
  157. package/src/plugin/internal/loadProjectPlugins.ts +36 -6
  158. package/src/structures/ITtscCompilerContext.ts +17 -2
  159. package/src/structures/ITtscCompilerResult.ts +8 -5
  160. package/src/structures/ITtscPluginCapabilities.ts +9 -0
  161. package/src/structures/ITtscPluginFactoryContext.ts +15 -0
  162. package/src/structures/internal/ITtscParsedProjectConfig.ts +3 -0
  163. package/src/structures/internal/ITtscProjectIdentity.ts +17 -0
  164. package/src/structures/internal/TtscCommonOptions.ts +8 -0
  165. package/tools/shim_audit/baseline.json +16 -7
  166. package/tools/shim_audit/main.go +777 -71
  167. package/tools/shim_audit/main_test.go +28 -1
  168. package/tools/shim_audit/producer_exemptions_require_rationale_test.go +42 -0
  169. package/tools/shim_audit/producer_surface_includes_exposed_methods_test.go +154 -0
  170. package/tools/shim_audit/producer_surface_rejects_unproducible_reference_test.go +142 -0
  171. package/utility/host.go +46 -20
  172. package/utility/serve.go +1 -1
  173. package/shim/checker/signature_api_completeness.go +0 -58
@@ -58,9 +58,14 @@ export class TtscService {
58
58
  * carries the host's diagnostics so callers can surface a real build error.
59
59
  */
60
60
  public async transformFile(fileName: string): Promise<string | undefined> {
61
- const reply = await this.resident.request({
62
- file: this.absolutePath(fileName),
63
- });
61
+ const reply = await this.resident.request(
62
+ { file: this.absolutePath(fileName) },
63
+ "transform",
64
+ );
65
+ // The resident client already validated the reply shape (boolean `found`,
66
+ // string `typescript` when found), so a malformed or wrong-shape reply
67
+ // rejected instead of reaching here. `found: false` is the only path to
68
+ // `undefined`; a protocol failure never masquerades as an absent file.
64
69
  return reply.found === true && typeof reply.typescript === "string"
65
70
  ? reply.typescript
66
71
  : undefined;
@@ -74,10 +79,13 @@ export class TtscService {
74
79
  * `fileName` is resolved against the project root.
75
80
  */
76
81
  public async updateFile(fileName: string, content: string): Promise<boolean> {
77
- const reply = await this.resident.request({
78
- content,
79
- update: this.absolutePath(fileName),
80
- });
82
+ const reply = await this.resident.request(
83
+ { content, update: this.absolutePath(fileName) },
84
+ "update",
85
+ );
86
+ // The resident client validated the reply carries a boolean `updated`, so a
87
+ // malformed or wrong-shape reply rejected instead of collapsing to `false`.
88
+ // `false` here means only that the edit did not compile.
81
89
  return reply.updated === true;
82
90
  }
83
91
 
@@ -36,7 +36,7 @@ export function compileProjectInMemory(options: ITtscCompilerContext): {
36
36
  projectRoot: options.projectRoot,
37
37
  tsconfig: options.tsconfig,
38
38
  });
39
- if (hasConfiguredPlugins(options, project)) {
39
+ if (shouldUsePluginBuild(options, project)) {
40
40
  return compileProjectWithPlugins(options, cwd, project);
41
41
  }
42
42
  const tsconfig = project.path;
@@ -82,6 +82,22 @@ function hasConfiguredPlugins(
82
82
  return hasProjectPluginEntries(project, options.plugins);
83
83
  }
84
84
 
85
+ /**
86
+ * Route plugin discovery failures through runBuild's recoverable setup path.
87
+ * The fast native-host lane cannot surface the plugin error or run the
88
+ * post-failure TypeScript check, while the plugin-backed lane can do both.
89
+ */
90
+ function shouldUsePluginBuild(
91
+ options: ITtscCompilerContext,
92
+ project: ITtscParsedProjectConfig,
93
+ ): boolean {
94
+ try {
95
+ return hasConfiguredPlugins(options, project);
96
+ } catch {
97
+ return true;
98
+ }
99
+ }
100
+
85
101
  /**
86
102
  * Plugin-backed compilation: emit into a temp directory via `runBuild`, then
87
103
  * read back every file the build wrote so they can be returned as strings.
@@ -104,6 +120,7 @@ function compileProjectWithPlugins(
104
120
  forceListEmittedFiles: true,
105
121
  outDir: tempOutDir,
106
122
  quiet: true,
123
+ resolvedProject: project,
107
124
  structuredDiagnostics: true,
108
125
  tsconfig: project.path,
109
126
  });
@@ -0,0 +1,15 @@
1
+ import type { ITtscParsedProjectConfig } from "../../../structures/internal/ITtscParsedProjectConfig";
2
+
3
+ /** Serialize the retained project identity for a native sidecar invocation. */
4
+ export function createNativeProjectContextArgs(
5
+ project: ITtscParsedProjectConfig,
6
+ pluginConfigOrigin?: string,
7
+ ): string[] {
8
+ return [
9
+ "--project-context-json=" +
10
+ JSON.stringify({
11
+ ...project.identity,
12
+ ...(pluginConfigOrigin === undefined ? {} : { pluginConfigOrigin }),
13
+ }),
14
+ ];
15
+ }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { ITtscProjectPluginConfig } from "../../../structures/ITtscProjectPluginConfig";
6
6
  import type { ITtscParsedProjectConfig } from "../../../structures/internal/ITtscParsedProjectConfig";
7
7
  import type { ITtscProjectLocatorOptions } from "../../../structures/internal/ITtscProjectLocatorOptions";
8
- import { resolveProjectConfig } from "./resolveProjectConfig";
8
+ import { resolveProjectIdentity } from "./resolveProjectConfig";
9
9
 
10
10
  /**
11
11
  * Compiler option keys whose values are file-system paths that must be resolved
@@ -40,10 +40,9 @@ type ResolvedCompilerOptions = {
40
40
  export function readProjectConfig(
41
41
  opts: ITtscProjectLocatorOptions = {},
42
42
  ): ITtscParsedProjectConfig {
43
- const tsconfig = resolveProjectConfig(opts);
44
- const root = opts.projectRoot
45
- ? path.resolve(opts.cwd ?? process.cwd(), opts.projectRoot)
46
- : path.dirname(tsconfig);
43
+ const identity = resolveProjectIdentity(opts);
44
+ const tsconfig = identity.physicalConfigPath;
45
+ const root = identity.physicalProjectRoot;
47
46
  const compilerOptions = readResolvedCompilerOptions(tsconfig);
48
47
  return {
49
48
  compilerOptions: {
@@ -51,6 +50,7 @@ export function readProjectConfig(
51
50
  outDir: compilerOptions.outDir,
52
51
  plugins: compilerOptions.plugins,
53
52
  },
53
+ identity,
54
54
  path: tsconfig,
55
55
  pluginBaseDirs: compilerOptions.pluginBaseDirs,
56
56
  root,
@@ -254,6 +254,14 @@ function resolveExtendsConfig(tsconfig: string, specifier: string): string {
254
254
  return resolveExistingExtendsPath(path.resolve(baseDir, specifier));
255
255
  }
256
256
  const resolver = createRequire(tsconfig);
257
+ // A bare package root selects its preset through `package.json#tsconfig`,
258
+ // matching TypeScript's config resolution. Presets shipped this way often
259
+ // have no JavaScript/JSON entrypoint at all, so Node's entrypoint resolver
260
+ // and the `<specifier>.json` fallback below both miss them.
261
+ const viaManifest = resolvePackageManifestTsconfig(resolver, specifier);
262
+ if (viaManifest !== undefined) {
263
+ return resolveExistingExtendsPath(viaManifest);
264
+ }
257
265
  try {
258
266
  return resolveRealPath(resolver.resolve(specifier));
259
267
  } catch {
@@ -261,6 +269,54 @@ function resolveExtendsConfig(tsconfig: string, specifier: string): string {
261
269
  }
262
270
  }
263
271
 
272
+ /**
273
+ * When `specifier` names a bare package root, resolve the config file its
274
+ * `package.json#tsconfig` field selects (anchored at the package directory).
275
+ * Returns `undefined` when the specifier is a subpath, the manifest is
276
+ * unresolvable/unparsable, or it declares no `tsconfig` field, so the caller
277
+ * falls back to Node entrypoint resolution.
278
+ */
279
+ function resolvePackageManifestTsconfig(
280
+ resolver: NodeRequire,
281
+ specifier: string,
282
+ ): string | undefined {
283
+ if (!isBarePackageRoot(specifier)) {
284
+ return undefined;
285
+ }
286
+ let manifestPath: string;
287
+ try {
288
+ manifestPath = resolver.resolve(`${specifier}/package.json`);
289
+ } catch {
290
+ return undefined;
291
+ }
292
+ let field: unknown;
293
+ try {
294
+ field = (
295
+ JSON.parse(stripLeadingBom(fs.readFileSync(manifestPath, "utf8"))) as {
296
+ tsconfig?: unknown;
297
+ }
298
+ ).tsconfig;
299
+ } catch {
300
+ return undefined;
301
+ }
302
+ if (typeof field !== "string" || field.length === 0) {
303
+ return undefined;
304
+ }
305
+ return path.resolve(path.dirname(manifestPath), field);
306
+ }
307
+
308
+ /**
309
+ * Return true when `specifier` is a bare package root (no subpath): a plain
310
+ * package name (`preset`) or a scoped name (`@scope/preset`). Subpaths such as
311
+ * `@scope/preset/base.json` resolve directly and keep their current meaning.
312
+ */
313
+ function isBarePackageRoot(specifier: string): boolean {
314
+ if (specifier.startsWith("@")) {
315
+ return specifier.split("/").length === 2;
316
+ }
317
+ return !specifier.includes("/");
318
+ }
319
+
264
320
  /**
265
321
  * Given an on-disk location, try the path as-is, with `.json` appended, and as
266
322
  * a directory containing `tsconfig.json`. Returns the first match or throws.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ import type { ITtscProjectIdentity } from "../../../structures/internal/ITtscProjectIdentity";
4
5
  import type { ITtscProjectLocatorOptions } from "../../../structures/internal/ITtscProjectLocatorOptions";
5
6
 
6
7
  /**
@@ -13,13 +14,29 @@ import type { ITtscProjectLocatorOptions } from "../../../structures/internal/IT
13
14
  * walking up from the file's directory.
14
15
  * 3. `opts.cwd` — the nearest ancestor config walking up from cwd.
15
16
  *
16
- * Always returns a real (symlink-resolved) absolute path. Throws when no config
17
- * is found or the explicitly supplied path does not exist.
17
+ * Returns the real (symlink-resolved) absolute config path. Use
18
+ * {@link resolveProjectIdentity} when both the caller-selected spelling and the
19
+ * Program identity are required.
18
20
  */
19
21
  export function resolveProjectConfig(
20
22
  opts: ITtscProjectLocatorOptions = {},
21
23
  ): string {
24
+ return resolveProjectIdentity(opts).physicalConfigPath;
25
+ }
26
+
27
+ /**
28
+ * Resolve the selected config while retaining its lexical spelling separately
29
+ * from the physical paths used by the TypeScript Program.
30
+ */
31
+ export function resolveProjectIdentity(
32
+ opts: ITtscProjectLocatorOptions = {},
33
+ ): Omit<ITtscProjectIdentity, "pluginConfigOrigin"> {
22
34
  const cwd = path.resolve(opts.cwd ?? process.cwd());
35
+ const explicitProjectRoot =
36
+ opts.projectRoot === undefined || opts.projectRoot === ""
37
+ ? undefined
38
+ : resolveAbsolutePath(cwd, opts.projectRoot);
39
+ let logicalConfigPath: string;
23
40
  if (opts.tsconfig) {
24
41
  const resolved = resolveAbsolutePath(cwd, opts.tsconfig);
25
42
  if (!fs.existsSync(resolved)) {
@@ -35,30 +52,43 @@ export function resolveProjectConfig(
35
52
  if (isDirectory(resolved)) {
36
53
  const tsconfigInDir = path.join(resolved, "tsconfig.json");
37
54
  if (fs.existsSync(tsconfigInDir)) {
38
- return resolveRealPath(tsconfigInDir);
39
- }
40
- const jsconfigInDir = path.join(resolved, "jsconfig.json");
41
- if (fs.existsSync(jsconfigInDir)) {
42
- return resolveRealPath(jsconfigInDir);
55
+ logicalConfigPath = tsconfigInDir;
56
+ } else {
57
+ const jsconfigInDir = path.join(resolved, "jsconfig.json");
58
+ if (fs.existsSync(jsconfigInDir)) {
59
+ logicalConfigPath = jsconfigInDir;
60
+ } else {
61
+ throw new Error(
62
+ `ttsc: directory has no tsconfig.json / jsconfig.json: ${resolved}`,
63
+ );
64
+ }
43
65
  }
66
+ } else {
67
+ logicalConfigPath = resolved;
68
+ }
69
+ } else {
70
+ const start = opts.file ? resolveAbsolutePath(cwd, opts.file) : cwd;
71
+ const from = isDirectory(start) ? start : path.dirname(start);
72
+ const found = findUp(from, ["tsconfig.json", "jsconfig.json"]);
73
+ if (!found) {
44
74
  throw new Error(
45
- `ttsc: directory has no tsconfig.json / jsconfig.json: ${resolved}`,
75
+ `ttsc: could not find tsconfig.json or jsconfig.json starting from ${from}`,
46
76
  );
47
77
  }
48
- return resolveRealPath(resolved);
49
- }
50
-
51
- const start = opts.file
52
- ? resolveRealPath(resolveAbsolutePath(cwd, opts.file))
53
- : cwd;
54
- const from = isDirectory(start) ? start : path.dirname(start);
55
- const found = findUp(from, ["tsconfig.json", "jsconfig.json"]);
56
- if (!found) {
57
- throw new Error(
58
- `ttsc: could not find tsconfig.json or jsconfig.json starting from ${from}`,
59
- );
78
+ logicalConfigPath = found;
60
79
  }
61
- return resolveRealPath(found);
80
+ const physicalConfigPath = resolveRealPath(logicalConfigPath);
81
+ const physicalProjectRoot = resolveRealPath(
82
+ explicitProjectRoot ?? path.dirname(physicalConfigPath),
83
+ );
84
+ return {
85
+ ...(explicitProjectRoot === undefined ? {} : { explicitProjectRoot }),
86
+ invocationCwd: cwd,
87
+ logicalConfigPath,
88
+ logicalProjectRoot: path.dirname(logicalConfigPath),
89
+ physicalConfigPath,
90
+ physicalProjectRoot,
91
+ };
62
92
  }
63
93
 
64
94
  /** Resolve `target` against `cwd` when it is not already absolute. */
@@ -4,6 +4,19 @@ import { type Interface, createInterface } from "node:readline";
4
4
  /** Cap on retained stderr so a long-lived host cannot grow it without bound. */
5
5
  const STDERR_TAIL_LIMIT = 64 * 1024;
6
6
 
7
+ /** Cap on how much of an offending line an error message echoes back. */
8
+ const REPLY_ECHO_LIMIT = 200;
9
+
10
+ /**
11
+ * The operation a request expects a reply for. The host answers a transform
12
+ * request (`{"file":...}`) with `{"typescript":...,"found":...}` and an update
13
+ * request (`{"update":...,"content":...}`) with `{"updated":...}`. The client
14
+ * knows which it sent, so it validates the reply's shape against the operation
15
+ * before handing it back — a well-formed JSON object of the wrong operation
16
+ * shape is a protocol error, not a valid negative result.
17
+ */
18
+ export type ResidentReplyKind = "transform" | "update";
19
+
7
20
  /** Options for spawning the resident transform host. */
8
21
  export interface ResidentTransformProcessOptions {
9
22
  args: readonly string[];
@@ -13,6 +26,7 @@ export interface ResidentTransformProcessOptions {
13
26
  }
14
27
 
15
28
  interface PendingRequest {
29
+ kind: ResidentReplyKind;
16
30
  reject: (reason: Error) => void;
17
31
  resolve: (reply: Record<string, unknown>) => void;
18
32
  }
@@ -78,13 +92,16 @@ export class ResidentTransformProcess {
78
92
  }
79
93
 
80
94
  /**
81
- * Send one request to the host and resolve with its parsed JSON reply. The
82
- * host answers in FIFO order, so the caller interprets the reply shape by the
83
- * payload it sent (a transform reply vs an update reply). Rejects when the
84
- * host has already failed or exited, or if writing the request fails.
95
+ * Send one request to the host and resolve with its validated JSON reply. The
96
+ * host answers in FIFO order; `kind` tells the client which reply shape the
97
+ * payload asks for, so the reply is validated as a well-formed `kind` reply
98
+ * before it resolves. Rejects when the reply is not a valid `kind` reply,
99
+ * when the host has already failed or exited, or if writing the request
100
+ * fails.
85
101
  */
86
102
  public request(
87
103
  payload: Record<string, unknown>,
104
+ kind: ResidentReplyKind,
88
105
  ): Promise<Record<string, unknown>> {
89
106
  if (this.failure !== undefined) {
90
107
  return Promise.reject(this.failure);
@@ -96,7 +113,7 @@ export class ResidentTransformProcess {
96
113
  );
97
114
  }
98
115
  return new Promise<Record<string, unknown>>((resolve, reject) => {
99
- const pending: PendingRequest = { reject, resolve };
116
+ const pending: PendingRequest = { kind, reject, resolve };
100
117
  this.pending.push(pending);
101
118
  stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
102
119
  if (error) {
@@ -149,7 +166,39 @@ export class ResidentTransformProcess {
149
166
  }
150
167
  return;
151
168
  }
152
- request.resolve(parseReply(trimmed));
169
+ const reply = parseReplyObject(trimmed);
170
+ if (reply === undefined) {
171
+ // Framing violation: the line is not a JSON object, so it cannot
172
+ // represent any reply. This request already left the FIFO, so reject it,
173
+ // then fail the whole process: a bad line may be corruption that shifted
174
+ // the stream, and the host's real reply that follows must not be paired
175
+ // with a later request. `fail` marks the failure so that trailing line is
176
+ // treated as benign instead of unsolicited.
177
+ const error = new Error(
178
+ `ttsc: resident transform host sent a malformed reply: ${echoLine(
179
+ trimmed,
180
+ )}`,
181
+ );
182
+ request.reject(error);
183
+ this.fail(error);
184
+ return;
185
+ }
186
+ if (!isValidReply(reply, request.kind)) {
187
+ // Operation-shape violation: a well-formed JSON object that is not a valid
188
+ // reply for the operation this request sent. FIFO framing is intact — one
189
+ // line consumed exactly one slot — so only this request is corrupt; later
190
+ // replies still pair correctly. Reject just this request instead of
191
+ // failing the whole process.
192
+ request.reject(
193
+ new Error(
194
+ `ttsc: resident transform host sent an invalid ${request.kind} reply: ${echoLine(
195
+ trimmed,
196
+ )}`,
197
+ ),
198
+ );
199
+ return;
200
+ }
201
+ request.resolve(reply);
153
202
  }
154
203
 
155
204
  private onReaderClose(): void {
@@ -194,23 +243,48 @@ export class ResidentTransformProcess {
194
243
  }
195
244
 
196
245
  /**
197
- * Parse one reply line into a plain object. A malformed line degrades to an
198
- * empty object rather than throwing, so one bad line never rejects a request
199
- * the FIFO has already advanced past; the caller reads missing fields as
200
- * absent.
246
+ * Parse one reply line into a plain object, or `undefined` when the line is not
247
+ * a JSON object. Invalid JSON, arrays, primitives, and `null` all yield
248
+ * `undefined`: none of them can carry a reply's fields, so the caller treats
249
+ * them as a framing failure rather than an empty reply. Returning `{}` here
250
+ * would let a corrupt line masquerade as a valid negative result.
201
251
  */
202
- function parseReply(line: string): Record<string, unknown> {
252
+ function parseReplyObject(line: string): Record<string, unknown> | undefined {
253
+ let parsed: unknown;
203
254
  try {
204
- const parsed = JSON.parse(line) as unknown;
205
- if (
206
- typeof parsed === "object" &&
207
- parsed !== null &&
208
- !Array.isArray(parsed)
209
- ) {
210
- return parsed as Record<string, unknown>;
211
- }
255
+ parsed = JSON.parse(line);
212
256
  } catch {
213
- // A non-object or unparseable line is treated as an empty reply.
257
+ return undefined;
258
+ }
259
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
260
+ return parsed as Record<string, unknown>;
261
+ }
262
+ return undefined;
263
+ }
264
+
265
+ /**
266
+ * Whether a parsed reply object is a well-formed reply for the given operation.
267
+ * A transform reply must carry a boolean `found`, and when `found` is `true` a
268
+ * string `typescript` (a found file always carries its transformed text); when
269
+ * `found` is `false` the text is irrelevant. An update reply must carry a
270
+ * boolean `updated`. Every other object shape is a protocol error.
271
+ */
272
+ function isValidReply(
273
+ reply: Record<string, unknown>,
274
+ kind: ResidentReplyKind,
275
+ ): boolean {
276
+ if (kind === "transform") {
277
+ if (typeof reply.found !== "boolean") {
278
+ return false;
279
+ }
280
+ return reply.found ? typeof reply.typescript === "string" : true;
214
281
  }
215
- return {};
282
+ return typeof reply.updated === "boolean";
283
+ }
284
+
285
+ /** Truncate an offending reply line so error messages stay bounded. */
286
+ function echoLine(line: string): string {
287
+ return line.length > REPLY_ECHO_LIMIT
288
+ ? `${line.slice(0, REPLY_ECHO_LIMIT)}…`
289
+ : line;
216
290
  }