ttsc 0.19.2 → 0.20.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 (178) hide show
  1. package/README.md +7 -5
  2. package/cmd/graphdump/main.go +2 -4
  3. package/cmd/ttsc/filter.go +12 -4
  4. package/cmd/ttsc/filter_resolves_flag_names_case_insensitively_test.go +53 -0
  5. package/cmd/ttsc/flags_gen.go +3 -3
  6. package/cmd/ttscgraph/dump.go +5 -7
  7. package/cmd/ttscgraph/run_dump_emits_only_checker_verified_member_relations_test.go +118 -0
  8. package/cmd/ttscgraph/serve.go +58 -308
  9. package/cmd/ttscgraph/serve_session_keeps_base_url_fallback_unchanged_test.go +49 -0
  10. package/cmd/ttscgraph/serve_session_keeps_esm_directory_candidate_unchanged_test.go +61 -0
  11. package/cmd/ttscgraph/serve_session_keeps_exports_blocked_file_candidates_unchanged_test.go +64 -0
  12. package/cmd/ttscgraph/serve_session_keeps_inactive_conditional_export_unchanged_test.go +66 -0
  13. package/cmd/ttscgraph/serve_session_keeps_lower_priority_module_candidate_unchanged_test.go +67 -0
  14. package/cmd/ttscgraph/serve_session_keeps_lower_priority_paths_pattern_unchanged_test.go +58 -0
  15. package/cmd/ttscgraph/serve_session_keeps_nonlongest_root_dirs_candidate_unchanged_test.go +54 -0
  16. package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +50 -0
  17. package/cmd/ttscgraph/serve_session_reloads_module_suffix_candidate_test.go +41 -0
  18. package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +216 -0
  19. package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +103 -0
  20. package/driver/graph.go +12 -6
  21. package/driver/lsp.go +30 -0
  22. package/driver/program.go +37 -1
  23. package/driver/resolution_candidates.go +819 -0
  24. package/driver/windowsjunction/windowsjunction.go +40 -0
  25. package/go.mod +2 -0
  26. package/internal/graph/build.go +45 -11
  27. package/internal/graph/dump.go +312 -108
  28. package/internal/graph/dump_checkout_paths_are_stable_test.go +210 -0
  29. package/internal/graph/dump_diagnostics_ride_the_generation_that_built_it_test.go +9 -3
  30. package/internal/graph/dump_paths.go +172 -0
  31. package/internal/graph/dump_paths_are_portable_and_injective_test.go +93 -0
  32. package/internal/graph/dump_schema_version_matches_the_typescript_client_test.go +1 -1
  33. package/internal/graph/dump_uses_ecma_line_terminators_test.go +87 -0
  34. package/internal/graph/duplicate_variable_object_members_keep_first_declaration_test.go +61 -0
  35. package/internal/graph/edges.go +220 -5
  36. package/internal/graph/enum_members_carry_their_names_on_the_enum_node_test.go +131 -0
  37. package/internal/graph/graph.go +177 -5
  38. package/internal/graph/line_model.go +122 -0
  39. package/internal/graph/literals.go +175 -0
  40. package/internal/graph/literals_are_absent_when_the_type_admits_more_than_it_can_name_test.go +74 -0
  41. package/internal/graph/literals_cover_enum_members_the_graph_has_no_nodes_for_test.go +79 -0
  42. package/internal/graph/literals_do_not_depend_on_declaration_line_wrapping_test.go +84 -0
  43. package/internal/graph/literals_render_every_value_in_typescript_source_form_test.go +77 -0
  44. package/internal/graph/literals_resolve_through_alias_indirection_test.go +64 -0
  45. package/internal/graph/member_relation_edges_follow_checker_validity_test.go +85 -0
  46. package/internal/graph/member_relation_edges_preserve_checker_boundaries_test.go +191 -0
  47. package/internal/graph/member_relation_edges_reject_checker_invalid_pairs_test.go +119 -0
  48. package/internal/graph/node_ids_preserve_hash_bearing_components_test.go +41 -0
  49. package/internal/graph/node_modifiers_emit_union_strings_test.go +4 -1
  50. package/internal/graph/object_literal_members_ride_on_the_snapshot_test.go +192 -0
  51. package/internal/graph/object_member_signatures_preserve_literal_whitespace_test.go +67 -0
  52. package/internal/graph/object_members.go +229 -0
  53. package/internal/graph/provenance.go +88 -30
  54. package/internal/graph/source_texts_cover_every_resident_program_source_test.go +73 -0
  55. package/internal/graphsymbols/position_helpers_follow_ecma_line_terminators_test.go +66 -0
  56. package/internal/graphsymbols/provider.go +26 -91
  57. package/internal/lspserver/lsp_clean_didopen_refreshes_compiler_state_test.go +144 -0
  58. package/internal/lspserver/lsp_completion.go +109 -0
  59. package/internal/lspserver/lsp_completion_gate_never_refuses_a_matching_line_test.go +50 -0
  60. package/internal/lspserver/lsp_completion_hints_longest_trigger_wins_test.go +149 -0
  61. package/internal/lspserver/lsp_completion_hints_nearest_trigger_wins_test.go +37 -0
  62. package/internal/lspserver/lsp_completion_merges_into_upstream_response_test.go +149 -0
  63. package/internal/lspserver/lsp_completion_nearest_hint_replaces_nearest_filter_test.go +43 -0
  64. package/internal/lspserver/lsp_completion_preserves_completion_list_extensions_test.go +99 -0
  65. package/internal/lspserver/lsp_completion_refuses_hints_inside_a_string_literal_test.go +69 -0
  66. package/internal/lspserver/lsp_completion_replaces_full_filter_in_rank_order_test.go +85 -0
  67. package/internal/lspserver/lsp_completion_request.go +455 -0
  68. package/internal/lspserver/lsp_completion_scope.go +530 -0
  69. package/internal/lspserver/lsp_completion_scope_bench_test.go +41 -0
  70. package/internal/lspserver/lsp_completion_scope_classifies_regex_statement_and_operator_boundaries_test.go +39 -0
  71. package/internal/lspserver/lsp_completion_scope_ignores_jsdoc_bytes_in_literals_test.go +108 -0
  72. package/internal/lspserver/lsp_completion_scope_refuses_regex_hints_test.go +52 -0
  73. package/internal/lspserver/lsp_completion_trigger_uses_last_rune_test.go +24 -0
  74. package/internal/lspserver/lsp_deleted_watched_file_withdraws_plugin_diagnostics_test.go +110 -0
  75. package/internal/lspserver/lsp_didsave_keeps_the_incremental_changed_uri_path_test.go +78 -0
  76. package/internal/lspserver/lsp_hints_absence_is_not_an_error_test.go +46 -0
  77. package/internal/lspserver/lsp_hints_refresh_coalesces_concurrent_requests_test.go +82 -0
  78. package/internal/lspserver/lsp_hints_refresh_ignores_a_superseded_generation_test.go +40 -0
  79. package/internal/lspserver/lsp_hints_refresh_keeps_each_producer_isolated_test.go +64 -0
  80. package/internal/lspserver/lsp_hints_refresh_replaces_the_session_snapshot_test.go +66 -0
  81. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +90 -0
  82. package/internal/lspserver/lsp_hints_refresh_serves_completion_while_running_test.go +91 -0
  83. package/internal/lspserver/lsp_index_byte_from_bounds_its_search_start_test.go +38 -0
  84. package/internal/lspserver/lsp_initialize_constrains_position_encoding_to_utf16_test.go +205 -0
  85. package/internal/lspserver/lsp_late_completion_trigger_is_reported_once_test.go +105 -0
  86. package/internal/lspserver/lsp_native_completion_hints_decode_flat_lint_wire_test.go +66 -0
  87. package/internal/lspserver/lsp_native_completion_hints_grouped_compatibility_boundary_test.go +60 -0
  88. package/internal/lspserver/lsp_native_plugin_source.go +315 -2
  89. package/internal/lspserver/lsp_native_resident.go +304 -0
  90. package/internal/lspserver/lsp_plugin.go +85 -8
  91. package/internal/lspserver/lsp_plugin_completion_resolve_stays_local_test.go +82 -0
  92. package/internal/lspserver/lsp_position_conversions_span_astral_and_combining_runes_test.go +119 -0
  93. package/internal/lspserver/lsp_proxy.go +601 -9
  94. package/internal/lspserver/lsp_refresh.go +70 -0
  95. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +43 -0
  96. package/internal/lspserver/lsp_resident_maps_verb_args_test.go +34 -0
  97. package/internal/lspserver/lsp_server.go +13 -1
  98. package/internal/lspserver/lsp_watched_file_changes_reach_compiler_caches_test.go +172 -0
  99. package/lib/TtscCompiler.d.ts +3 -1
  100. package/lib/TtscCompiler.js +5 -1
  101. package/lib/TtscCompiler.js.map +1 -1
  102. package/lib/TtscService.d.ts +13 -3
  103. package/lib/TtscService.js +6 -6
  104. package/lib/TtscService.js.map +1 -1
  105. package/lib/compiler/internal/project/readConfigJson.d.ts +30 -0
  106. package/lib/compiler/internal/project/readConfigJson.js +208 -0
  107. package/lib/compiler/internal/project/readConfigJson.js.map +1 -0
  108. package/lib/compiler/internal/project/readProjectConfig.js +53 -149
  109. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  110. package/lib/compiler/internal/residentTransformProcess.d.ts +15 -2
  111. package/lib/compiler/internal/residentTransformProcess.js +159 -39
  112. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  113. package/lib/compiler/internal/runBuild.d.ts +6 -0
  114. package/lib/compiler/internal/runBuild.js +143 -44
  115. package/lib/compiler/internal/runBuild.js.map +1 -1
  116. package/lib/compiler/internal/startResidentTransform.d.ts +5 -1
  117. package/lib/compiler/internal/startResidentTransform.js +3 -1
  118. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  119. package/lib/compiler/internal/transformProjectInMemory.js +12 -2
  120. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  121. package/lib/flags/parser.d.ts +19 -0
  122. package/lib/flags/parser.js +78 -36
  123. package/lib/flags/parser.js.map +1 -1
  124. package/lib/flags/schema.d.ts +49 -11
  125. package/lib/flags/schema.js +94 -22
  126. package/lib/flags/schema.js.map +1 -1
  127. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +5 -0
  128. package/lib/internal/assertSafeExplicitCacheDirectory.js +26 -0
  129. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +1 -0
  130. package/lib/launcher/internal/runTtsc.js +107 -69
  131. package/lib/launcher/internal/runTtsc.js.map +1 -1
  132. package/lib/launcher/internal/runTtsx.js +28 -41
  133. package/lib/launcher/internal/runTtsx.js.map +1 -1
  134. package/lib/launcher/internal/runtimeHooks.d.ts +15 -0
  135. package/lib/launcher/internal/runtimeHooks.js +18 -1
  136. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  137. package/lib/launcher/internal/watchTopology.d.ts +43 -0
  138. package/lib/launcher/internal/watchTopology.js +360 -0
  139. package/lib/launcher/internal/watchTopology.js.map +1 -0
  140. package/lib/plugin/internal/buildSourcePlugin.d.ts +14 -3
  141. package/lib/plugin/internal/buildSourcePlugin.js +247 -61
  142. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  143. package/lib/plugin/internal/loadProjectPlugins.js +63 -2
  144. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  145. package/lib/structures/ITtscCompilerTransformation.d.ts +16 -0
  146. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +2 -0
  147. package/package.json +8 -8
  148. package/shim/ast/shim.go +48 -3
  149. package/shim/astnav/go.mod +14 -0
  150. package/shim/astnav/go.sum +20 -0
  151. package/shim/astnav/shim.go +15 -0
  152. package/shim/checker/shim.go +113 -0
  153. package/shim/core/shim.go +47 -0
  154. package/shim/diagnosticwriter/lint.go +50 -0
  155. package/src/TtscCompiler.ts +5 -1
  156. package/src/TtscService.ts +37 -10
  157. package/src/compiler/internal/project/readConfigJson.ts +209 -0
  158. package/src/compiler/internal/project/readProjectConfig.ts +62 -158
  159. package/src/compiler/internal/residentTransformProcess.ts +197 -39
  160. package/src/compiler/internal/runBuild.ts +169 -47
  161. package/src/compiler/internal/startResidentTransform.ts +12 -1
  162. package/src/compiler/internal/transformProjectInMemory.ts +12 -1
  163. package/src/flags/parser.ts +113 -42
  164. package/src/flags/schema.ts +113 -31
  165. package/src/internal/assertSafeExplicitCacheDirectory.ts +29 -0
  166. package/src/launcher/internal/runTtsc.ts +117 -68
  167. package/src/launcher/internal/runTtsx.ts +29 -44
  168. package/src/launcher/internal/runtimeHooks.ts +30 -2
  169. package/src/launcher/internal/watchTopology.ts +466 -0
  170. package/src/plugin/internal/buildSourcePlugin.ts +326 -64
  171. package/src/plugin/internal/loadProjectPlugins.ts +80 -2
  172. package/src/structures/ITtscCompilerTransformation.ts +17 -0
  173. package/src/structures/internal/ITtscParsedProjectConfig.ts +2 -0
  174. package/tools/shim_audit/baseline.json +0 -3
  175. package/tools/shim_audit/main.go +42 -14
  176. package/tools/shim_audit/shim_dir_coverage_test.go +25 -0
  177. package/utility/flags_gen.go +3 -3
  178. package/utility/host.go +9 -3
@@ -4,7 +4,10 @@ import { loadProjectPlugins } from "../../plugin/internal/loadProjectPlugins";
4
4
  import type { ITtscCompilerContext } from "../../structures/ITtscCompilerContext";
5
5
  import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
6
6
  import { readProjectConfig } from "./project/readProjectConfig";
7
- import { ResidentTransformProcess } from "./residentTransformProcess";
7
+ import {
8
+ ResidentTransformProcess,
9
+ normalizeRequestTimeoutMs,
10
+ } from "./residentTransformProcess";
8
11
  import { resolveBinary } from "./resolveBinary";
9
12
  import { resolveTsgo } from "./resolveTsgo";
10
13
  import {
@@ -23,6 +26,11 @@ export interface StartedResidentTransform {
23
26
  projectRoot: string;
24
27
  }
25
28
 
29
+ /** Construction controls owned by the resident request client. */
30
+ export interface StartResidentTransformOptions {
31
+ requestTimeoutMs?: number;
32
+ }
33
+
26
34
  /**
27
35
  * Start a resident `serve` host for the configured project.
28
36
  *
@@ -40,7 +48,9 @@ export interface StartedResidentTransform {
40
48
  */
41
49
  export function startResidentTransform(
42
50
  context: ITtscCompilerContext,
51
+ options: StartResidentTransformOptions = {},
43
52
  ): StartedResidentTransform {
53
+ const requestTimeoutMs = normalizeRequestTimeoutMs(options.requestTimeoutMs);
44
54
  const cwd = path.resolve(context.cwd ?? process.cwd());
45
55
  const project = readProjectConfig({
46
56
  cwd,
@@ -80,6 +90,7 @@ export function startResidentTransform(
80
90
  binary: host.binary,
81
91
  cwd: project.root,
82
92
  env: residentEnv(context, tsgoBinary, loaded.nativePlugins),
93
+ requestTimeoutMs,
83
94
  });
84
95
  return { process: resident, projectRoot: project.root };
85
96
  }
@@ -488,6 +488,12 @@ function parseDependencyLists(
488
488
  * `dependencies`: non-object sections are dropped, edge entries that are not
489
489
  * string arrays are dropped, and non-string list members are filtered. A
490
490
  * section carrying nothing usable collapses to `undefined`.
491
+ *
492
+ * `candidates` is the one optional member, so an empty one is left off the
493
+ * result instead of being materialized as `{}`. The host omits the key when it
494
+ * has no superseding candidate to report, and a consumer that narrows on the
495
+ * declared optional type must see the same shape whether it reads the decoded
496
+ * envelope or the wire.
491
497
  */
492
498
  function parseReferenceGraph(
493
499
  value: unknown,
@@ -496,21 +502,26 @@ function parseReferenceGraph(
496
502
  return undefined;
497
503
  }
498
504
  const section = value as {
505
+ candidates?: unknown;
499
506
  configs?: unknown;
500
507
  edges?: unknown;
501
508
  globals?: unknown;
502
509
  };
510
+ const candidates = parseDependencyLists(section.candidates) ?? {};
503
511
  const edges = parseDependencyLists(section.edges) ?? {};
504
512
  const globals = parseFileList(section.globals) ?? [];
505
513
  const configs = parseFileList(section.configs) ?? [];
506
514
  if (
515
+ Object.keys(candidates).length === 0 &&
507
516
  Object.keys(edges).length === 0 &&
508
517
  globals.length === 0 &&
509
518
  configs.length === 0
510
519
  ) {
511
520
  return undefined;
512
521
  }
513
- return { configs, edges, globals };
522
+ return Object.keys(candidates).length === 0
523
+ ? { configs, edges, globals }
524
+ : { candidates, configs, edges, globals };
514
525
  }
515
526
 
516
527
  /**
@@ -11,7 +11,11 @@
11
11
  // The schema is the spec; behaviour at every layer boundary is determined by
12
12
  // the FlagSpec attributes, not by ad-hoc `if (arg === "--foo")` branches.
13
13
  import type { AnySubcommand, FlagSpec, ValueValidator } from "./schema";
14
- import { FLAG_BY_NAME, flagsForSubcommand } from "./schema";
14
+ import {
15
+ flagsForSubcommand,
16
+ normalizeFlagToken,
17
+ resolveFlagSpec,
18
+ } from "./schema";
15
19
 
16
20
  /**
17
21
  * Per-subcommand parse result. `values` is keyed by the canonical flag name
@@ -22,6 +26,16 @@ import { FLAG_BY_NAME, flagsForSubcommand } from "./schema";
22
26
  export interface ParseResult {
23
27
  /** Canonical flag name → resolved value. */
24
28
  readonly values: ReadonlyMap<string, string | boolean | number>;
29
+ /**
30
+ * Canonical flag name → every accepted value, in argv order. Populated only
31
+ * for flags declared `repeatable` in `FLAG_SCHEMA` (`ttsx -r a -r b`), where
32
+ * the last-value-wins `values` entry is not the whole answer. Read it through
33
+ * `getStringList`.
34
+ */
35
+ readonly repeated: ReadonlyMap<
36
+ string,
37
+ readonly (string | boolean | number)[]
38
+ >;
25
39
  /** Flags the engine did not consume — forwarded to tsgo. */
26
40
  readonly passthrough: readonly string[];
27
41
  /** Bare non-flag positional arguments, in original order. */
@@ -73,6 +87,10 @@ export interface ParseOptions {
73
87
  * arity from the flag itself (it has no schema for a truly unknown flag); the
74
88
  * predicate is the only signal that separates a forwarded value from a real
75
89
  * input file, and both callers key it on the TypeScript source extension.
90
+ *
91
+ * Every path that can move a bare token out of `positional` consults it: the
92
+ * main loop below and `forwardKnownButUnaccepted`, which answers the same
93
+ * question for a schema-known flag this subcommand does not accept.
76
94
  */
77
95
  readonly isPositional?: (token: string) => boolean;
78
96
  }
@@ -96,11 +114,14 @@ export function parseFlags(opts: ParseOptions): ParseResult {
96
114
  // flag at the launcher boundary (the RC-1 / RC-2 class the schema
97
115
  // is meant to make impossible).
98
116
  if (!flag.consumedBy.includes("launcher")) continue;
99
- accepted.set(flag.name, flag);
100
- for (const alias of flag.aliases ?? []) accepted.set(alias, flag);
117
+ accepted.set(normalizeFlagToken(flag.name), flag);
118
+ for (const alias of flag.aliases ?? []) {
119
+ accepted.set(normalizeFlagToken(alias), flag);
120
+ }
101
121
  }
102
122
 
103
123
  const values = new Map<string, string | boolean | number>();
124
+ const repeated = new Map<string, (string | boolean | number)[]>();
104
125
  const passthrough: string[] = [];
105
126
  const positional: string[] = [];
106
127
  const tail: string[] = [];
@@ -130,42 +151,61 @@ export function parseFlags(opts: ParseOptions): ParseResult {
130
151
  continue;
131
152
  }
132
153
 
133
- // `--foo=value` / `-p=value` form: split before resolving against the
134
- // schema. Both long (`--foo`) and short (`-p`) aliases support the
135
- // inline-value shape — without splitting the short form, `-p=value`
136
- // would fall through as an unknown token and be forwarded to tsgo,
137
- // bypassing the launcher's own consumer (e.g. plugin discovery against
138
- // the wrong project root).
139
- const equalsIndex = current.startsWith("-") ? current.indexOf("=") : -1;
140
- const token = equalsIndex === -1 ? current : current.slice(0, equalsIndex);
141
- const inlineValue =
142
- equalsIndex === -1 ? undefined : current.slice(equalsIndex + 1);
154
+ // Only a `-`-prefixed token can name a flag. Bare tokens are input files
155
+ // and flag values; resolving one against the schema would let the `all` of
156
+ // `--target all` masquerade as `--all` now that the lookup is dash- and
157
+ // case-insensitive.
158
+ if (current.startsWith("-")) {
159
+ // `--foo=value` / `-p=value` form: split before resolving against the
160
+ // schema. Both long (`--foo`) and short (`-p`) aliases support the
161
+ // inline-value shape — without splitting the short form, `-p=value`
162
+ // would fall through as an unknown token and be forwarded to tsgo,
163
+ // bypassing the launcher's own consumer (e.g. plugin discovery against
164
+ // the wrong project root).
165
+ const equalsIndex = current.indexOf("=");
166
+ const token =
167
+ equalsIndex === -1 ? current : current.slice(0, equalsIndex);
168
+ const inlineValue =
169
+ equalsIndex === -1 ? undefined : current.slice(equalsIndex + 1);
143
170
 
144
- const flag = accepted.get(token);
145
- if (flag !== undefined) {
146
- consumeFlag(values, flag, token, inlineValue, head, opts.errorPrefix);
147
- continue;
148
- }
171
+ // Resolution is by flag identity, not by exact spelling: `--NOEMIT` and
172
+ // `-noEmit` are the same option to the compiler ttsc forwards to, so they
173
+ // must be the same option here. Otherwise a case variant of a ttsc-owned
174
+ // flag falls through to the escape hatch below and tsgo honours it while
175
+ // every ttsc-side consumer stays silent.
176
+ const flag = accepted.get(normalizeFlagToken(token));
177
+ if (flag !== undefined) {
178
+ consumeFlag(
179
+ values,
180
+ repeated,
181
+ flag,
182
+ token,
183
+ inlineValue,
184
+ head,
185
+ opts.errorPrefix,
186
+ );
187
+ continue;
188
+ }
149
189
 
150
- // Token IS a known flag but is not accepted by THIS subcommand. The
151
- // engine forwards it to tsgo just like an unknown flag — the same
152
- // policy the bare-lane parser applied (RC-1 prevention).
153
- const globalFlag = FLAG_BY_NAME.get(token);
154
- if (globalFlag !== undefined) {
155
- forwardKnownButUnaccepted(
156
- passthrough,
157
- globalFlag,
158
- current,
159
- inlineValue,
160
- head,
161
- );
162
- continue;
163
- }
190
+ // Token IS a known flag but is not accepted by THIS subcommand. The
191
+ // engine forwards it to tsgo just like an unknown flag — the same
192
+ // policy the bare-lane parser applied (RC-1 prevention).
193
+ const globalFlag = resolveFlagSpec(token);
194
+ if (globalFlag !== undefined) {
195
+ forwardKnownButUnaccepted(
196
+ passthrough,
197
+ globalFlag,
198
+ current,
199
+ inlineValue,
200
+ head,
201
+ opts.isPositional,
202
+ );
203
+ continue;
204
+ }
164
205
 
165
- // Truly unknown `-`-prefixed token: forward to tsgo verbatim. This
166
- // is what makes `ttsc --strict file.ts` work — ttsc does not need to
167
- // re-implement every tsgo flag.
168
- if (current.startsWith("-")) {
206
+ // Truly unknown `-`-prefixed token: forward to tsgo verbatim. This
207
+ // is what makes `ttsc --strict file.ts` work — ttsc does not need to
208
+ // re-implement every tsgo flag.
169
209
  passthrough.push(current);
170
210
  continue;
171
211
  }
@@ -194,7 +234,7 @@ export function parseFlags(opts: ParseOptions): ParseResult {
194
234
  for (const token of remainder) sink.push(token);
195
235
  }
196
236
 
197
- return { values, passthrough, positional, tail };
237
+ return { values, repeated, passthrough, positional, tail };
198
238
  }
199
239
 
200
240
  /**
@@ -204,12 +244,20 @@ export function parseFlags(opts: ParseOptions): ParseResult {
204
244
  */
205
245
  function consumeFlag(
206
246
  values: Map<string, string | boolean | number>,
247
+ repeated: Map<string, (string | boolean | number)[]>,
207
248
  flag: FlagSpec,
208
249
  token: string,
209
250
  inlineValue: string | undefined,
210
251
  rest: string[],
211
252
  errorPrefix: string,
212
253
  ): void {
254
+ const record = (value: string | boolean | number): void => {
255
+ values.set(flag.name, value);
256
+ if (flag.repeatable !== true) return;
257
+ const list = repeated.get(flag.name);
258
+ if (list === undefined) repeated.set(flag.name, [value]);
259
+ else list.push(value);
260
+ };
213
261
  if (flag.kind === "boolean") {
214
262
  if (inlineValue !== undefined) {
215
263
  // `--flag=false` / `--flag=true` inline form. Anything other than
@@ -224,7 +272,7 @@ function consumeFlag(
224
272
  )}`,
225
273
  );
226
274
  }
227
- values.set(flag.name, literal);
275
+ record(literal);
228
276
  return;
229
277
  }
230
278
  // Space form `--flag true` / `--flag false`: peek the next token and
@@ -236,11 +284,11 @@ function consumeFlag(
236
284
  const peek = parseBooleanLiteral(rest[0]!);
237
285
  if (peek !== undefined) {
238
286
  rest.shift();
239
- values.set(flag.name, peek);
287
+ record(peek);
240
288
  return;
241
289
  }
242
290
  }
243
- values.set(flag.name, true);
291
+ record(true);
244
292
  return;
245
293
  }
246
294
 
@@ -249,10 +297,10 @@ function consumeFlag(
249
297
  ? inlineValue
250
298
  : takeValueToken(token, rest, errorPrefix);
251
299
  if (flag.validator === "positiveInt") {
252
- values.set(flag.name, validatePositiveInt(token, raw, errorPrefix));
300
+ record(validatePositiveInt(token, raw, errorPrefix));
253
301
  return;
254
302
  }
255
- values.set(flag.name, raw);
303
+ record(raw);
256
304
  }
257
305
 
258
306
  /**
@@ -320,6 +368,14 @@ function validatePositiveInt(
320
368
  * accept. The launcher will hand it to tsgo (or to native sidecars via
321
369
  * `--tsgo-args`); without this branch the parser would lose the value token of
322
370
  * a `--flag value` pair.
371
+ *
372
+ * `isPositional` is the same predicate the main loop applies, and applying it
373
+ * here is what makes the file's own claim true — that the two value-resolution
374
+ * paths agree, and that the predicate is the only signal separating a forwarded
375
+ * value from a real input. Without it this branch took the next bare token on
376
+ * behalf of a flag that does not own it, so `ttsc --pretty a.ts` lost `a.ts`
377
+ * out of `positional` and silently switched from single-file mode to project
378
+ * mode (issue #663's failure shape, reached through the sibling branch).
323
379
  */
324
380
  function forwardKnownButUnaccepted(
325
381
  passthrough: string[],
@@ -327,6 +383,7 @@ function forwardKnownButUnaccepted(
327
383
  original: string,
328
384
  inlineValue: string | undefined,
329
385
  rest: string[],
386
+ isPositional: ((token: string) => boolean) | undefined,
330
387
  ): void {
331
388
  passthrough.push(original);
332
389
  // Boolean flags carry no value. `--foo=value` already encodes the value
@@ -337,6 +394,7 @@ function forwardKnownButUnaccepted(
337
394
  }
338
395
  if (rest.length === 0) return;
339
396
  if (rest[0]!.startsWith("-")) return;
397
+ if (isPositional !== undefined && isPositional(rest[0]!)) return;
340
398
  passthrough.push(rest.shift()!);
341
399
  }
342
400
 
@@ -374,6 +432,19 @@ export function getString(
374
432
  return undefined;
375
433
  }
376
434
 
435
+ /**
436
+ * Return every string value accepted for a `repeatable` flag, in argv order.
437
+ *
438
+ * `values` keeps only the last occurrence, which is the wrong answer for a flag
439
+ * whose whole point is repetition (`ttsx -r a -r b` preloads both). Returns an
440
+ * empty array when the flag never appeared.
441
+ */
442
+ export function getStringList(result: ParseResult, flag: string): string[] {
443
+ return (result.repeated.get(flag) ?? []).filter(
444
+ (value): value is string => typeof value === "string",
445
+ );
446
+ }
447
+
377
448
  /** Return the numeric value of `flag` or `undefined` if not present. */
378
449
  export function getNumber(
379
450
  result: ParseResult,
@@ -26,6 +26,7 @@
26
26
  export type TtscSubcommand =
27
27
  | "ttsc"
28
28
  | "build"
29
+ | "cache"
29
30
  | "check"
30
31
  | "fix"
31
32
  | "format"
@@ -130,6 +131,26 @@ export interface FlagSpec {
130
131
  */
131
132
  readonly terminal?: boolean;
132
133
 
134
+ /**
135
+ * `true` when a `terminal` flag's meaning does not presuppose a resolved
136
+ * project, so ttsc must answer it before project resolution runs (`--init`
137
+ * writes the starter tsconfig, `--all` and `-?` print tsgo's help). Without
138
+ * this split `ttsc --init` failed with "could not find tsconfig.json …" in
139
+ * the only directory where it is useful.
140
+ *
141
+ * `--showConfig` and `--listFilesOnly` are terminal but deliberately NOT
142
+ * project-free: both describe a project, so failing without one is correct.
143
+ */
144
+ readonly projectFree?: boolean;
145
+
146
+ /**
147
+ * `true` when every occurrence of a repeated `value` flag counts rather than
148
+ * the last one winning (`ttsx -r a -r b` preloads both). The engine keeps the
149
+ * last value in `ParseResult.values` for callers that want a single answer
150
+ * and records the complete ordered list in `ParseResult.repeated`.
151
+ */
152
+ readonly repeatable?: boolean;
153
+
133
154
  /**
134
155
  * `true` when ttsc may add this flag to tsgo internally and post-process the
135
156
  * output. If the user also forwards the same flag, ttsc keeps the
@@ -201,6 +222,7 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
201
222
  "ttsc",
202
223
  "ttsx",
203
224
  "build",
225
+ "cache",
204
226
  "check",
205
227
  "fix",
206
228
  "format",
@@ -217,6 +239,7 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
217
239
  "ttsc",
218
240
  "ttsx",
219
241
  "build",
242
+ "cache",
220
243
  "check",
221
244
  "fix",
222
245
  "format",
@@ -316,6 +339,7 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
316
339
  "ttsc",
317
340
  "ttsx",
318
341
  "build",
342
+ "cache",
319
343
  "check",
320
344
  "fix",
321
345
  "format",
@@ -325,6 +349,13 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
325
349
  consumedBy: ["launcher"],
326
350
  description: "Override the runner and source-plugin cache root.",
327
351
  },
352
+ {
353
+ name: "--json",
354
+ kind: "boolean",
355
+ subcommands: ["cache"],
356
+ consumedBy: ["launcher"],
357
+ description: "Print cache paths as JSON.",
358
+ },
328
359
 
329
360
  // -------------------------------------------------------------------------
330
361
  // Threading (tsgo-native; lint host opts in via capability).
@@ -354,9 +385,10 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
354
385
  name: "--require",
355
386
  aliases: ["-r"],
356
387
  kind: "value",
388
+ repeatable: true,
357
389
  subcommands: ["ttsx"],
358
390
  consumedBy: ["launcher"],
359
- description: "Preload a module before the entrypoint (ttsx).",
391
+ description: "Preload a module before the entrypoint (ttsx; repeatable).",
360
392
  },
361
393
  {
362
394
  name: "--no-plugins",
@@ -382,8 +414,14 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
382
414
  "Print the list of emitted files (forwarded to tsgo; ttsc keeps the lines when forwarded).",
383
415
  },
384
416
  {
417
+ // tsgo declares `--pretty` as `type: boolean`, so it occupies one argv
418
+ // token and consumes a following one only when that token is the literal
419
+ // `true` or `false` — the shape the engine's boolean branch implements.
420
+ // Declaring it `value` made the forwarding path swallow whatever followed,
421
+ // so `ttsc --pretty a.ts` lost its input file and silently switched to
422
+ // project mode.
385
423
  name: "--pretty",
386
- kind: "value",
424
+ kind: "boolean",
387
425
  subcommands: ["ttsc", "ttsx", "build", "check", "fix", "format"],
388
426
  consumedBy: ["tsgo"],
389
427
  forwardTo: "tsgo",
@@ -435,6 +473,7 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
435
473
  consumedBy: ["tsgo"],
436
474
  forwardTo: "tsgo",
437
475
  terminal: true,
476
+ projectFree: true,
438
477
  description: "Print the full tsgo CLI help and exit.",
439
478
  },
440
479
  {
@@ -444,8 +483,24 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
444
483
  consumedBy: ["tsgo"],
445
484
  forwardTo: "tsgo",
446
485
  terminal: true,
486
+ projectFree: true,
447
487
  description: "Write a starter tsconfig.json and exit (forwarded to tsgo).",
448
488
  },
489
+ {
490
+ // tsgo's short synonym for `--help`. ttsc owns `--help` / `-h` itself (the
491
+ // launcher prints its own help), so `-?` is declared as its own tsgo-only
492
+ // row rather than as an alias — that keeps `ttsc -?` printing tsgo's help
493
+ // while putting the token inside the schema, where the terminal and
494
+ // project-free classifications are derived from.
495
+ name: "-?",
496
+ kind: "boolean",
497
+ subcommands: ["ttsc", "build", "check"],
498
+ consumedBy: ["tsgo"],
499
+ forwardTo: "tsgo",
500
+ terminal: true,
501
+ projectFree: true,
502
+ description: "Print the tsgo CLI help and exit (forwarded to tsgo).",
503
+ },
449
504
 
450
505
  // -------------------------------------------------------------------------
451
506
  // `--tsgo-args` — JSON-encoded passthrough envelope the launcher emits on
@@ -512,10 +567,50 @@ export const FLAG_SCHEMA: readonly FlagSpec[] = [
512
567
  ];
513
568
 
514
569
  /**
515
- * Lookup of `name` and every alias → its canonical FlagSpec. Built once at
516
- * module load so the parsing engine has O(1) flag resolution.
570
+ * Normalize a CLI token to the identity the compiler ttsc wraps resolves it by:
571
+ * one or two leading dashes removed, the remainder lower-cased.
572
+ *
573
+ * TypeScript's option parser — legacy `tsc` and native tsgo alike — strips a
574
+ * `--` or `-` prefix and matches the rest case-insensitively, so `--noEmit`,
575
+ * `--noemit`, `--NOEMIT`, and `-noEmit` all name the same option to the tool
576
+ * ttsc forwards to. The launcher used to key its index on the exact spelling,
577
+ * so a case variant of a ttsc-owned flag fell through the unknown-flag escape
578
+ * hatch: tsgo honoured it and every ttsc-side consumer of the same flag never
579
+ * fired, with no diagnostic.
580
+ *
581
+ * This is the single normalization. Everything that resolves a token against
582
+ * `FLAG_SCHEMA` — the parsing engine, the terminal / shadow / project-free
583
+ * classifications, and the generated Go allow-lists — keys off this function,
584
+ * so no two layers can disagree about which flag a spelling names.
585
+ */
586
+ export function normalizeFlagToken(token: string): string {
587
+ return token.replace(/^--?/, "").toLowerCase();
588
+ }
589
+
590
+ /**
591
+ * Resolve a raw argv token to the flag it names, or `undefined` when the schema
592
+ * claims no such flag.
593
+ *
594
+ * Accepts every spelling the compiler accepts — any casing, one or two leading
595
+ * dashes — plus the inline `--flag=value` form, whose value is not part of the
596
+ * identity. A token without a leading dash is never a flag: bare tokens are
597
+ * input files and flag values, and resolving them here would let a value like
598
+ * the `all` of `--target all` masquerade as `--all`.
599
+ */
600
+ export function resolveFlagSpec(token: string): FlagSpec | undefined {
601
+ if (!token.startsWith("-")) return undefined;
602
+ const equalsIndex = token.indexOf("=");
603
+ const name = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
604
+ return FLAG_BY_TOKEN.get(normalizeFlagToken(name));
605
+ }
606
+
607
+ /**
608
+ * Lookup of every declared spelling → its canonical FlagSpec, keyed by
609
+ * {@link normalizeFlagToken}. Built once at module load so the parsing engine
610
+ * has O(1) flag resolution. Prefer {@link resolveFlagSpec}, which applies the
611
+ * normalization for the caller.
517
612
  */
518
- export const FLAG_BY_NAME: ReadonlyMap<string, FlagSpec> = buildFlagIndex();
613
+ export const FLAG_BY_TOKEN: ReadonlyMap<string, FlagSpec> = buildFlagIndex();
519
614
 
520
615
  function buildFlagIndex(): ReadonlyMap<string, FlagSpec> {
521
616
  const index = new Map<string, FlagSpec>();
@@ -530,39 +625,22 @@ function buildFlagIndex(): ReadonlyMap<string, FlagSpec> {
530
625
 
531
626
  function register(
532
627
  index: Map<string, FlagSpec>,
533
- key: string,
628
+ spelling: string,
534
629
  flag: FlagSpec,
535
630
  ): void {
631
+ // Two spellings that normalize to one identity would make the schema
632
+ // ambiguous, so the collision fails loudly at module load rather than
633
+ // resolving to whichever row was declared last.
634
+ const key = normalizeFlagToken(spelling);
536
635
  const existing = index.get(key);
537
636
  if (existing && existing !== flag) {
538
637
  throw new Error(
539
- `ttsc flag schema: duplicate token ${JSON.stringify(key)} claimed by ${existing.name} and ${flag.name}`,
638
+ `ttsc flag schema: duplicate token ${JSON.stringify(spelling)} claimed by ${existing.name} and ${flag.name}`,
540
639
  );
541
640
  }
542
641
  index.set(key, flag);
543
642
  }
544
643
 
545
- /**
546
- * Terminal flags as a derived set. Used by `runBuild.ts` to skip the pre-emit
547
- * pass when one is present.
548
- */
549
- export const TERMINAL_FLAGS: ReadonlySet<string> = new Set(
550
- FLAG_SCHEMA.flatMap((flag) =>
551
- flag.terminal === true ? [flag.name, ...(flag.aliases ?? [])] : [],
552
- ),
553
- );
554
-
555
- /**
556
- * `internalShadow: true` flags as a derived set. Used by `runBuild.ts` to
557
- * detect a user-forwarded copy of a flag ttsc adds internally so the
558
- * post-processor does not eat the user's output.
559
- */
560
- export const INTERNAL_SHADOW_FLAGS: ReadonlySet<string> = new Set(
561
- FLAG_SCHEMA.flatMap((flag) =>
562
- flag.internalShadow === true ? [flag.name, ...(flag.aliases ?? [])] : [],
563
- ),
564
- );
565
-
566
644
  /** Tokens (canonical name + aliases) accepted in `subcommand`. */
567
645
  export function flagsForSubcommand(subcommand: AnySubcommand): FlagSpec[] {
568
646
  return FLAG_SCHEMA.filter((flag) => flag.subcommands.includes(subcommand));
@@ -581,9 +659,13 @@ export function buildGoAllowList(layer: "host" | "lint"): Map<string, boolean> {
581
659
  if (!flag.consumedBy.includes(layer)) continue;
582
660
  const takesValue = flag.kind === "value" || flag.kind === "valueOptional";
583
661
  for (const name of [flag.name, ...(flag.aliases ?? [])]) {
584
- const key = name.replace(/^--?/, "");
585
- // The Go side strips the leading dashes; if two flags collide on the
586
- // stripped key (e.g. `-p` vs `--project`), use the value-taking shape.
662
+ // Keyed by the one normalization the runtime token lookup uses, so the
663
+ // generated allow-lists and `resolveFlagSpec` cannot recognise different
664
+ // spellings. The Go consumers apply the same normalization before the
665
+ // lookup (`strings.ToLower` on the dash-stripped name).
666
+ const key = normalizeFlagToken(name);
667
+ // If two flags collide on the normalized key (e.g. `-p` vs `--project`),
668
+ // use the value-taking shape.
587
669
  const existing = out.get(key);
588
670
  if (existing !== undefined && existing !== takesValue) {
589
671
  throw new Error(
@@ -0,0 +1,29 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Refuse an explicit cache directory whose wholesale removal could delete the
5
+ * project or an entire filesystem volume.
6
+ */
7
+ export function assertSafeExplicitCacheDirectory(
8
+ projectRoot: string,
9
+ cacheDirectory: string,
10
+ ): void {
11
+ const project = path.resolve(projectRoot);
12
+ const cache = path.resolve(cacheDirectory);
13
+ if (cache === path.parse(cache).root) {
14
+ throw new Error(
15
+ `ttsc: refusing to clean cache directory ${JSON.stringify(cache)} because filesystem roots are never valid cache directories`,
16
+ );
17
+ }
18
+ const relative = path.relative(cache, project);
19
+ if (
20
+ relative === "" ||
21
+ (relative !== ".." &&
22
+ !relative.startsWith(`..${path.sep}`) &&
23
+ !path.isAbsolute(relative))
24
+ ) {
25
+ throw new Error(
26
+ `ttsc: refusing to clean cache directory ${JSON.stringify(cache)} because it equals or contains project root ${JSON.stringify(project)}; choose a dedicated cache directory`,
27
+ );
28
+ }
29
+ }