@esportsplus/typescript 0.31.0 → 0.31.1

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 (74) hide show
  1. package/README.md +86 -0
  2. package/bin/tsc-lsp +3 -0
  3. package/build/cli/diagnostics.d.ts +5 -1
  4. package/build/cli/diagnostics.js +13 -8
  5. package/build/cli/tsc.d.ts +3 -1
  6. package/build/cli/tsc.js +77 -87
  7. package/build/compiler/coordinator.d.ts +1 -2
  8. package/build/compiler/coordinator.js +33 -22
  9. package/build/compiler/imports.d.ts +2 -0
  10. package/build/compiler/imports.js +9 -2
  11. package/build/compiler/language-service.d.ts +15 -4
  12. package/build/compiler/language-service.js +56 -24
  13. package/build/compiler/plugins/vite.js +7 -5
  14. package/build/compiler/sourcemap.d.ts +3 -1
  15. package/build/compiler/sourcemap.js +23 -5
  16. package/build/jsonc.d.ts +2 -0
  17. package/build/jsonc.js +85 -0
  18. package/build/lsp/bin.d.ts +1 -0
  19. package/build/lsp/bin.js +2 -0
  20. package/build/lsp/diagnostics.d.ts +10 -0
  21. package/build/lsp/diagnostics.js +69 -0
  22. package/build/lsp/index.d.ts +2 -0
  23. package/build/lsp/index.js +2 -0
  24. package/build/lsp/server.d.ts +4 -0
  25. package/build/lsp/server.js +130 -0
  26. package/build/lsp/workspace.d.ts +14 -0
  27. package/build/lsp/workspace.js +46 -0
  28. package/build/probe/adapter.d.ts +14 -0
  29. package/build/probe/adapter.js +42 -0
  30. package/build/probe/async/channel.d.ts +4 -0
  31. package/build/probe/async/channel.js +560 -0
  32. package/build/probe/async/value.d.ts +9 -0
  33. package/build/probe/async/value.js +16 -0
  34. package/build/probe/channels.d.ts +4 -0
  35. package/build/probe/channels.js +16 -0
  36. package/build/probe/exceptions/channel.d.ts +5 -0
  37. package/build/probe/exceptions/channel.js +657 -0
  38. package/build/probe/exceptions/jsdoc.d.ts +8 -0
  39. package/build/probe/exceptions/jsdoc.js +34 -0
  40. package/build/probe/exceptions/value.d.ts +39 -0
  41. package/build/probe/exceptions/value.js +158 -0
  42. package/build/probe/kernel/analyze.d.ts +8 -0
  43. package/build/probe/kernel/analyze.js +68 -0
  44. package/build/probe/kernel/ast.d.ts +11 -0
  45. package/build/probe/kernel/ast.js +49 -0
  46. package/build/probe/kernel/config.d.ts +5 -0
  47. package/build/probe/kernel/config.js +209 -0
  48. package/build/probe/kernel/fixpoint.d.ts +6 -0
  49. package/build/probe/kernel/fixpoint.js +206 -0
  50. package/build/probe/kernel/format.d.ts +4 -0
  51. package/build/probe/kernel/format.js +32 -0
  52. package/build/probe/kernel/graph.d.ts +4 -0
  53. package/build/probe/kernel/graph.js +507 -0
  54. package/build/probe/kernel/ids.d.ts +8 -0
  55. package/build/probe/kernel/ids.js +66 -0
  56. package/build/probe/kernel/program.d.ts +8 -0
  57. package/build/probe/kernel/program.js +13 -0
  58. package/build/probe/kernel/types.d.ts +134 -0
  59. package/build/probe/kernel/types.js +1 -0
  60. package/build/probe/overlay/base/async.jsonc +13 -0
  61. package/build/probe/overlay/base/exceptions.jsonc +54 -0
  62. package/build/probe/overlay/base/resources.jsonc +57 -0
  63. package/build/probe/overlay/load.d.ts +18 -0
  64. package/build/probe/overlay/load.js +302 -0
  65. package/build/probe/overlay/presets/express.jsonc +25 -0
  66. package/build/probe/overlay/presets/node.jsonc +17 -0
  67. package/build/probe/resources/channel.d.ts +4 -0
  68. package/build/probe/resources/channel.js +798 -0
  69. package/build/probe/resources/value.d.ts +9 -0
  70. package/build/probe/resources/value.js +36 -0
  71. package/build/tsconfig.d.ts +2 -0
  72. package/build/tsconfig.js +150 -0
  73. package/package.json +10 -4
  74. package/tsconfig.base.json +19 -0
package/README.md CHANGED
@@ -18,6 +18,7 @@ Extends the TypeScript compiler with a plugin architecture for custom AST transf
18
18
  - Vite plugin for dev/build integration
19
19
  - CLI wrapper for `tsc` with automatic plugin detection
20
20
  - Language service caching for incremental compilation
21
+ - `analyze` throw-safety analysis on the `tsc` passthrough and over LSP
21
22
 
22
23
  ## Usage
23
24
 
@@ -74,6 +75,83 @@ tsc
74
75
 
75
76
  The CLI detects plugins in `tsconfig.json` `compilerOptions.plugins`, loads them, runs coordinated compilation, and automatically calls `tsc-alias` afterward. The package installs `tsc`/`tsc-alias` bins (also under the unambiguous `esportsplus-tsc`/`esportsplus-tsc-alias` names).
76
77
 
78
+ ## analyze
79
+
80
+ `analyze` is a static analyzer for effects TypeScript's types leave implicit. It runs a per-function summary fixpoint over the call graph — so a finding points at the *consumer*, the call site where a caller should be careful — and is organised into **channels**, each checking one class of effect:
81
+
82
+ - **`exceptions`** — calls that may throw with no `catch` on the path to a handler boundary, `@throws` under-declaration, and `catch` rethrows that drop the caught error's `cause`. Carries the throw origin as related information.
83
+ - **`resources`** — acquired resources (timers, event listeners, file handles, sockets, `Disposable`s, …) that can leak: not released, transferred, or `using`-bound on every path — including throwing paths, which it derives from the `exceptions` channel's summaries.
84
+ - **`async`** — unbounded `Promise.all(…)`-style fan-out, orphaned promises whose rejections go unhandled, and awaited cancellable calls that drop an `AbortSignal` the function holds.
85
+
86
+ Only `exceptions` is on by default; enable the others per project. It rides the `tsc` passthrough (build/CI), and ships an LSP server so editors can render the same findings.
87
+
88
+ ### Enable
89
+
90
+ Add an `analyze` entry to `compilerOptions.plugins`. Analysis covers every file the tsconfig includes (ignoring excludes); no config beyond the entry is required.
91
+
92
+ ```jsonc
93
+ {
94
+ "compilerOptions": {
95
+ "plugins": [
96
+ {
97
+ "name": "ts-probe",
98
+ // Editor squiggle color; "warn" opts down. CLI/build ignore this.
99
+ "severity": "error",
100
+ // Fail the tsc/build run when there are findings.
101
+ "failOnFindings": true,
102
+ // Per-channel config. `enabled` and `dispatch` are recognised on
103
+ // every channel; other keys are that channel's own options.
104
+ "channels": {
105
+ "exceptions": {
106
+ "enabled": true,
107
+ // "consumers" (default): uncaught calls only.
108
+ // "cross-module": only when the throwing callee is in another package.
109
+ // "all": throws AND uncaught calls.
110
+ "report": "consumers",
111
+ // Flag `throw`s inside `catch` that drop the caught error's cause.
112
+ "errorCause": true
113
+ },
114
+ "resources": {
115
+ "enabled": true,
116
+ // Untrackable handling when a resource escapes local analysis:
117
+ // "optimist" assumes transfer (silent), "pessimist" reports it.
118
+ "dispatch": "optimist",
119
+ // Calls that take ownership of a passed resource argument.
120
+ "ownership": [{ "callee": "registerCleanup", "params": [0] }]
121
+ },
122
+ "async": {
123
+ "enabled": true,
124
+ "fanOut": "warn", // "off" | "warn" | "error"
125
+ "fanOutAllowLiteralUpTo": 16, // inline array/tuple size that is fine
126
+ "poolFunctions": ["p-limit", "p-map"] // sanctioned concurrency wrappers
127
+ }
128
+ },
129
+ // Model third-party throw behavior: "node", "express".
130
+ "presets": ["node"]
131
+ }
132
+ ]
133
+ }
134
+ }
135
+ ```
136
+
137
+ ### CLI / build
138
+
139
+ The `tsc` passthrough runs analyze after a successful compile and prints findings to stderr. With `failOnFindings: true`, a run with findings exits non-zero — drop it into CI as a gate.
140
+
141
+ ```bash
142
+ tsc # compiles, resolves aliases, then reports analyze findings
143
+ ```
144
+
145
+ ### Editor (LSP)
146
+
147
+ The package ships a standalone language server (`esportsplus-tsc-lsp` bin, or the `@esportsplus/typescript/lsp` export) that publishes analyze findings over LSP. A client spawns it beside the native TypeScript server and merges both diagnostic streams; `severity` drives the squiggle color. Analysis runs against saved files on open and save. Beyond diagnostics it serves **hovers** (the finding plus its origin→boundary chain) and **quick-fixes** — `void`/`await` an orphaned promise, forward an `AbortSignal`, or fix a leaked handle by converting it to `using` or wrapping the region in `try/finally`.
148
+
149
+ ```typescript
150
+ import { startServer } from '@esportsplus/typescript/lsp';
151
+
152
+ startServer(); // stdio LSP server
153
+ ```
154
+
77
155
  ## API
78
156
 
79
157
  ### `@esportsplus/typescript`
@@ -91,6 +169,14 @@ No exports. Import the TypeScript compiler API directly from `typescript/unstabl
91
169
  | `plugin` | Built-in plugins (`tsc`, `vite`) |
92
170
  | `uid` | Unique identifier generation |
93
171
 
172
+ ### `@esportsplus/typescript/lsp`
173
+
174
+ | Export | Description |
175
+ |---|---|
176
+ | `startServer` | Start the stdio analyze LSP server |
177
+ | `createServer` | Wire the server onto an existing JSON-RPC connection |
178
+ | `AnalyzeWorkspace` | Long-lived native session that re-runs the analysis |
179
+
94
180
  ### Types
95
181
 
96
182
  ```typescript
package/bin/tsc-lsp ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import '../build/lsp/bin.js';
@@ -1,4 +1,8 @@
1
1
  import { type Diagnostic } from 'typescript/unstable/sync';
2
+ import type { PositionMapping } from '../compiler/sourcemap.js';
2
3
  declare const flatten: (diagnostic: Diagnostic, indent?: number) => string;
3
- declare const format: (diagnostics: readonly Diagnostic[], root: string) => string;
4
+ declare const format: (diagnostics: readonly Diagnostic[], root: string, transformed?: Map<string, {
5
+ code: string;
6
+ mapping: PositionMapping;
7
+ }>) => string;
4
8
  export { flatten, format };
@@ -2,6 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import { computeLineStarts } from 'typescript/unstable/ast/scanner';
3
3
  import { DiagnosticCategory } from 'typescript/unstable/sync';
4
4
  import path from 'path';
5
+ import { resolveOffset } from '../compiler/sourcemap.js';
5
6
  const ANSI_BLUE = '\x1b[94m';
6
7
  const ANSI_CYAN = '\x1b[96m';
7
8
  const ANSI_GREY = '\x1b[90m';
@@ -34,16 +35,18 @@ function categoryLabel(category) {
34
35
  return 'message';
35
36
  }
36
37
  }
37
- function formatOne(diagnostic, root) {
38
+ function formatOne(diagnostic, root, sources, transformed) {
38
39
  let category = categoryLabel(diagnostic.category), code = `${ANSI_GREY}TS${diagnostic.code}${ANSI_RESET}`, color = categoryColor(diagnostic.category), message = flatten(diagnostic);
39
40
  if (diagnostic.fileName === undefined) {
40
41
  return `${color}${category}${ANSI_RESET} ${code}: ${message}`;
41
42
  }
42
- let location = `${ANSI_CYAN}${path.relative(root, diagnostic.fileName).replace(BACKSLASH_REGEX, '/')}${ANSI_RESET}`, text = readSource(diagnostic.fileName);
43
+ let location = `${ANSI_CYAN}${path.relative(root, diagnostic.fileName).replace(BACKSLASH_REGEX, '/')}${ANSI_RESET}`, entry = transformed?.get(diagnostic.fileName.replace(BACKSLASH_REGEX, '/')), end = entry ? resolveOffset(entry.mapping, diagnostic.end) : diagnostic.end, pos = entry ? resolveOffset(entry.mapping, diagnostic.pos) : diagnostic.pos, sourceInfo = sources.get(diagnostic.fileName) ?? readSourceInfo(diagnostic.fileName);
44
+ sources.set(diagnostic.fileName, sourceInfo);
45
+ const text = sourceInfo.text;
43
46
  if (text === undefined) {
44
47
  return `${location} - ${color}${category}${ANSI_RESET} ${code}: ${message}`;
45
48
  }
46
- let lineStarts = computeLineStarts(text), line = lineOfPosition(lineStarts, diagnostic.pos), character = diagnostic.pos - lineStarts[line], lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1] : text.length, source = text.slice(lineStarts[line], lineEnd).replace(TRAILING_NEWLINE_REGEX, ''), width = Math.max(1, Math.min(diagnostic.end, lineEnd) - diagnostic.pos), underline = `${' '.repeat(character)}${color}${'~'.repeat(width)}${ANSI_RESET}`, header = `${location}:${ANSI_YELLOW}${line + 1}${ANSI_RESET}:${ANSI_YELLOW}${character + 1}${ANSI_RESET} - ${color}${category}${ANSI_RESET} ${code}: ${message}`;
49
+ let lineStarts = sourceInfo.lineStarts, line = lineOfPosition(lineStarts, pos), character = pos - lineStarts[line], lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1] : text.length, source = text.slice(lineStarts[line], lineEnd).replace(TRAILING_NEWLINE_REGEX, ''), width = Math.max(1, Math.min(end, lineEnd) - pos), underline = `${' '.repeat(character)}${color}${'~'.repeat(width)}${ANSI_RESET}`, header = `${location}:${ANSI_YELLOW}${line + 1}${ANSI_RESET}:${ANSI_YELLOW}${character + 1}${ANSI_RESET} - ${color}${category}${ANSI_RESET} ${code}: ${message}`;
47
50
  return `${header}\n\n${source}\n${underline}`;
48
51
  }
49
52
  function lineOfPosition(lineStarts, position) {
@@ -59,12 +62,13 @@ function lineOfPosition(lineStarts, position) {
59
62
  }
60
63
  return high < 0 ? 0 : high;
61
64
  }
62
- function readSource(fileName) {
65
+ function readSourceInfo(fileName) {
63
66
  try {
64
- return readFileSync(fileName, 'utf8');
67
+ const text = readFileSync(fileName, 'utf8');
68
+ return { lineStarts: computeLineStarts(text), text };
65
69
  }
66
70
  catch {
67
- return undefined;
71
+ return { lineStarts: [], text: undefined };
68
72
  }
69
73
  }
70
74
  const flatten = (diagnostic, indent = 0) => {
@@ -80,10 +84,11 @@ const flatten = (diagnostic, indent = 0) => {
80
84
  }
81
85
  return result;
82
86
  };
83
- const format = (diagnostics, root) => {
87
+ const format = (diagnostics, root, transformed) => {
84
88
  let parts = [];
89
+ const sources = new Map();
85
90
  for (let i = 0, n = diagnostics.length; i < n; i++) {
86
- parts.push(formatOne(diagnostics[i], root));
91
+ parts.push(formatOne(diagnostics[i], root, sources, transformed));
87
92
  }
88
93
  return parts.join('\n\n');
89
94
  };
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from '../compiler/types.js';
2
2
  import { API } from 'typescript/unstable/sync';
3
+ import { stripJsonc } from '../jsonc.js';
3
4
  type PluginConfig = {
4
5
  transform: string;
5
6
  };
@@ -13,6 +14,7 @@ declare function isPlugin(value: unknown): value is Plugin;
13
14
  declare function loadPlugins(configs: PluginConfig[], root: string): Promise<Plugin[]>;
14
15
  declare function main(): void;
15
16
  declare function normalizePath(fileName: string): string;
17
+ declare function projectPath(args: string[]): string | null;
16
18
  declare function resolvePluginConfigs(tsconfig: string): PluginConfig[];
17
19
  declare function runTscAlias(args: string[]): Promise<number>;
18
- export { build, classifyFlags, isPlugin, loadPlugins, main, normalizePath, resolvePluginConfigs, runTscAlias };
20
+ export { build, classifyFlags, isPlugin, loadPlugins, main, normalizePath, projectPath, resolvePluginConfigs, runTscAlias, stripJsonc };
package/build/cli/tsc.js CHANGED
@@ -1,4 +1,4 @@
1
- import { API, DiagnosticCategory } from 'typescript/unstable/sync';
1
+ import { DiagnosticCategory } from 'typescript/unstable/sync';
2
2
  import { createRequire } from 'module';
3
3
  import { format } from './diagnostics.js';
4
4
  import { PACKAGE_NAME } from '../constants.js';
@@ -9,30 +9,36 @@ import fs from 'fs';
9
9
  import languageService from '../compiler/language-service.js';
10
10
  import path from 'path';
11
11
  import sourcemap from '../compiler/sourcemap.js';
12
+ import { analyze, analyzeProgram } from '../probe/kernel/analyze.js';
13
+ import { formatDiagnostics } from '../probe/kernel/format.js';
14
+ import { loadConfigFromTsconfig } from '../probe/kernel/config.js';
15
+ import { stripJsonc } from '../jsonc.js';
16
+ import { readPlugins } from '../tsconfig.js';
12
17
  const BACKSLASH_REGEX = /\\/g;
13
18
  const INFORMATIONAL_FLAGS = new Set(['--help', '--init', '--showConfig', '--version', '-h', '-v']);
14
19
  const NO_EMIT_FLAGS = new Set(['--noEmit', '-noEmit']);
15
20
  const WATCH_FLAGS = new Set(['--watch', '-w']);
16
- let require = createRequire(import.meta.url), skipFlags = new Set(['--help', '--init', '--noEmit', '--showConfig', '--version', '-h', '-noEmit', '-v']);
21
+ let require = createRequire(import.meta.url), skipFlags = new Set([...INFORMATIONAL_FLAGS, ...NO_EMIT_FLAGS]);
17
22
  async function build(tsconfig, pluginConfigs, instance, noEmit = false) {
18
- let root = path.dirname(path.resolve(tsconfig)), owned = instance === undefined, api = instance ?? new API({ cwd: root }), snapshot = api.updateSnapshot({ openProjects: [tsconfig] }), project = snapshot.getProject(tsconfig);
23
+ let opened = instance === undefined ? languageService.open(tsconfig) : undefined, root = path.dirname(path.resolve(tsconfig)), owned = instance === undefined, api = opened?.api ?? instance, snapshot = opened?.snapshot ?? api.updateSnapshot({ openProjects: [tsconfig] }), project = opened?.project ?? snapshot.getProject(tsconfig);
19
24
  if (!project) {
20
- teardown(snapshot, api, root, owned);
25
+ teardown(snapshot, tsconfig, owned);
21
26
  throw new Error(`${PACKAGE_NAME}: project not found for ${tsconfig}`);
22
27
  }
23
28
  let configDiagnostics = project.program.getConfigFileParsingDiagnostics();
24
29
  if (configDiagnostics.some((diagnostic) => diagnostic.category === DiagnosticCategory.Error)) {
25
30
  console.error(format(configDiagnostics, root));
26
- teardown(snapshot, api, root, owned);
31
+ teardown(snapshot, tsconfig, owned);
27
32
  process.exit(1);
28
33
  }
29
34
  let { fileNames, options } = api.parseConfigFile(tsconfig), plugins, shared = new Map(), transformedFiles = new Map();
35
+ runAnalyze(tsconfig, project.program, project.checker);
30
36
  try {
31
37
  plugins = await loadPlugins(pluginConfigs, root);
32
38
  }
33
39
  catch (error) {
34
40
  console.error(error instanceof Error ? error.message : String(error));
35
- teardown(snapshot, api, root, owned);
41
+ teardown(snapshot, tsconfig, owned);
36
42
  process.exit(1);
37
43
  }
38
44
  for (let i = 0, n = fileNames.length; i < n; i++) {
@@ -40,15 +46,14 @@ async function build(tsconfig, pluginConfigs, instance, noEmit = false) {
40
46
  if (!sourceFile) {
41
47
  continue;
42
48
  }
43
- let result = coordinator.transform(plugins, sourceFile.getFullText(), sourceFile, { checker: project.checker, program: project.program }, root, shared);
49
+ let result = coordinator.transform(plugins, sourceFile.getFullText(), sourceFile, { checker: project.checker, configPath: tsconfig, program: project.program }, root, shared);
44
50
  if (result.changed) {
45
51
  transformedFiles.set(normalizePath(fileName), { code: result.code, mapping: result.map });
46
52
  }
47
53
  }
48
- let program = project.program;
49
- for (let [fileName, entry] of transformedFiles) {
50
- program = languageService.update(root, fileName, entry.code).program;
51
- }
54
+ let program = transformedFiles.size === 0
55
+ ? project.program
56
+ : languageService.updateMany(tsconfig, new Map([...transformedFiles].map(([fileName, entry]) => [fileName, entry.code]))).program;
52
57
  let diagnostics = [
53
58
  ...program.getConfigFileParsingDiagnostics(),
54
59
  ...program.getSyntacticDiagnostics(),
@@ -58,18 +63,18 @@ async function build(tsconfig, pluginConfigs, instance, noEmit = false) {
58
63
  ...program.getProgramDiagnostics()
59
64
  ];
60
65
  if (diagnostics.length > 0) {
61
- console.error(format(diagnostics, root));
66
+ console.error(format(diagnostics, root, transformedFiles));
62
67
  }
63
68
  if (diagnostics.some((diagnostic) => diagnostic.category === DiagnosticCategory.Error)) {
64
- teardown(snapshot, api, root, owned);
69
+ teardown(snapshot, tsconfig, owned);
65
70
  process.exit(1);
66
71
  }
67
72
  if (noEmit) {
68
- teardown(snapshot, api, root, owned);
73
+ teardown(snapshot, tsconfig, owned);
69
74
  process.exit(0);
70
75
  }
71
76
  let code = await emit(tsconfig, fileNames, transformedFiles, root, options);
72
- teardown(snapshot, api, root, owned);
77
+ teardown(snapshot, tsconfig, owned);
73
78
  if (code !== 0) {
74
79
  process.exit(code);
75
80
  }
@@ -187,36 +192,6 @@ async function emit(tsconfig, fileNames, transformedFiles, root, options) {
187
192
  fs.rmSync(mirror, { force: true, recursive: true });
188
193
  }
189
194
  }
190
- function extendsTarget(specifier, fromDir) {
191
- if (typeof specifier !== 'string') {
192
- return null;
193
- }
194
- if (specifier.startsWith('.')) {
195
- let resolved = path.resolve(fromDir, specifier);
196
- if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
197
- return resolved;
198
- }
199
- if (fs.existsSync(resolved + '.json')) {
200
- return resolved + '.json';
201
- }
202
- let nested = path.join(resolved, 'tsconfig.json');
203
- if (fs.existsSync(nested)) {
204
- return nested;
205
- }
206
- return null;
207
- }
208
- try {
209
- return require.resolve(specifier, { paths: [fromDir] });
210
- }
211
- catch {
212
- try {
213
- return require.resolve(specifier + '/tsconfig.json', { paths: [fromDir] });
214
- }
215
- catch {
216
- return null;
217
- }
218
- }
219
- }
220
195
  function isPlugin(value) {
221
196
  return typeof value === 'object' && value !== null && 'transform' in value && typeof value.transform === 'function';
222
197
  }
@@ -254,22 +229,24 @@ async function loadPlugins(configs, root) {
254
229
  return plugins;
255
230
  }
256
231
  function main() {
257
- let tsconfig = languageService.findConfig(process.cwd());
232
+ let args = process.argv.slice(2), tsconfig = projectPath(args) ?? languageService.findConfig(process.cwd());
258
233
  if (!tsconfig) {
259
234
  return passthrough();
260
235
  }
236
+ let flags = classifyFlags(args);
261
237
  let pluginConfigs = resolvePluginConfigs(tsconfig);
262
238
  if (pluginConfigs.length === 0) {
239
+ if (!flags.informational && !flags.watch) {
240
+ runAnalyze(tsconfig);
241
+ }
263
242
  return passthrough();
264
243
  }
265
- let flags = classifyFlags(process.argv.slice(2));
266
244
  if (flags.informational) {
267
245
  return passthrough();
268
246
  }
269
247
  if (flags.watch) {
270
248
  console.error(`${PACKAGE_NAME}: --watch is not supported on the transformer plugin path; run a one-shot build or use real tsc directly`);
271
249
  process.exit(1);
272
- return;
273
250
  }
274
251
  console.log(`${PACKAGE_NAME}: found ${pluginConfigs.length} transformer plugin(s), using coordinated build...`);
275
252
  build(tsconfig, pluginConfigs, undefined, flags.noEmit).catch((err) => {
@@ -280,6 +257,49 @@ function main() {
280
257
  function normalizePath(fileName) {
281
258
  return path.resolve(fileName).replace(BACKSLASH_REGEX, '/');
282
259
  }
260
+ function projectPath(args) {
261
+ for (let i = 0, n = args.length; i < n; i++) {
262
+ let arg = args[i], value;
263
+ if (arg === '-p' || arg === '--project') {
264
+ value = args[i + 1];
265
+ }
266
+ else if (arg.startsWith('--project=')) {
267
+ value = arg.slice('--project='.length);
268
+ }
269
+ if (value !== undefined) {
270
+ let resolved = path.resolve(value);
271
+ return fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()
272
+ ? path.join(resolved, 'tsconfig.json')
273
+ : resolved;
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+ function runAnalyze(tsconfig, program, checker) {
279
+ let config;
280
+ try {
281
+ config = loadConfigFromTsconfig(tsconfig);
282
+ }
283
+ catch (error) {
284
+ console.error(`${PACKAGE_NAME}: analyze config error: ${error instanceof Error ? error.message : String(error)}`);
285
+ return;
286
+ }
287
+ if (!config) {
288
+ return;
289
+ }
290
+ try {
291
+ let result = program && checker ? analyzeProgram(program, checker, config) : analyze(config);
292
+ if (result.diagnostics.length > 0) {
293
+ console.error(formatDiagnostics(result.diagnostics, config.projectRoot));
294
+ if (config.failOnFindings) {
295
+ process.exit(1);
296
+ }
297
+ }
298
+ }
299
+ catch (error) {
300
+ console.error(`${PACKAGE_NAME}: analyze failed: ${error instanceof Error ? error.message : String(error)}`);
301
+ }
302
+ }
283
303
  function passthrough() {
284
304
  let args = process.argv.slice(2), tsDir = path.dirname(require.resolve('typescript/package.json'));
285
305
  spawn(process.execPath, [path.join(tsDir, 'lib', 'tsc.js'), ...args], { stdio: 'inherit' })
@@ -290,39 +310,8 @@ function passthrough() {
290
310
  process.exit(code ?? 0);
291
311
  });
292
312
  }
293
- function readPlugins(tsconfig, seen) {
294
- let id = path.resolve(tsconfig);
295
- if (seen.has(id)) {
296
- return undefined;
297
- }
298
- seen.add(id);
299
- let config;
300
- try {
301
- config = JSON.parse(stripJsonc(fs.readFileSync(id, 'utf8')));
302
- }
303
- catch {
304
- return undefined;
305
- }
306
- let plugins;
307
- if (config?.extends !== undefined) {
308
- let bases = Array.isArray(config.extends) ? config.extends : [config.extends];
309
- for (let i = 0, n = bases.length; i < n; i++) {
310
- let target = extendsTarget(bases[i], path.dirname(id));
311
- if (target) {
312
- let inherited = readPlugins(target, seen);
313
- if (inherited !== undefined) {
314
- plugins = inherited;
315
- }
316
- }
317
- }
318
- }
319
- if (Array.isArray(config?.compilerOptions?.plugins)) {
320
- plugins = config.compilerOptions.plugins;
321
- }
322
- return plugins;
323
- }
324
313
  function resolvePluginConfigs(tsconfig) {
325
- let plugins = readPlugins(tsconfig, new Set());
314
+ let plugins = readPlugins(tsconfig);
326
315
  if (!Array.isArray(plugins)) {
327
316
  return [];
328
317
  }
@@ -345,7 +334,7 @@ function spawnTsc(tscJs, args) {
345
334
  child.on('exit', (code) => resolve(code ?? 0));
346
335
  });
347
336
  }
348
- function stripJsonc(text) {
337
+ function legacyStripJsonc(text) {
349
338
  let escaped = false, inBlockComment = false, inLineComment = false, inString = false, stripped = '';
350
339
  for (let i = 0, n = text.length; i < n; i++) {
351
340
  let char = text[i], next = text[i + 1];
@@ -429,16 +418,17 @@ function stripJsonc(text) {
429
418
  }
430
419
  return result;
431
420
  }
432
- function teardown(snapshot, api, root, owned) {
421
+ void legacyStripJsonc;
422
+ function teardown(snapshot, configPath, owned) {
423
+ if (owned) {
424
+ languageService.dispose(configPath);
425
+ return;
426
+ }
433
427
  if (!snapshot.isDisposed()) {
434
428
  snapshot.dispose();
435
429
  }
436
- if (owned) {
437
- api.close();
438
- }
439
- languageService.dispose(root);
440
430
  }
441
431
  if (process.env.VITEST === undefined) {
442
432
  main();
443
433
  }
444
- export { build, classifyFlags, isPlugin, loadPlugins, main, normalizePath, resolvePluginConfigs, runTscAlias };
434
+ export { build, classifyFlags, isPlugin, loadPlugins, main, normalizePath, projectPath, resolvePluginConfigs, runTscAlias, stripJsonc };
@@ -6,10 +6,10 @@ type CoordinatorResult = {
6
6
  changed: boolean;
7
7
  code: string;
8
8
  map: PositionMapping;
9
- sourceFile: SourceFile;
10
9
  };
11
10
  declare const transform: (plugins: Plugin[], code: string, file: SourceFile, project: {
12
11
  checker: Checker;
12
+ configPath?: string;
13
13
  program: Program;
14
14
  }, root: string, shared: SharedContext) => {
15
15
  changed: boolean;
@@ -17,7 +17,6 @@ declare const transform: (plugins: Plugin[], code: string, file: SourceFile, pro
17
17
  map: {
18
18
  generations: OffsetAnchor[][];
19
19
  };
20
- sourceFile: SourceFile;
21
20
  };
22
21
  declare const _default: {
23
22
  transform: typeof transform;
@@ -26,18 +26,15 @@ function applyImports(code, file, intents) {
26
26
  });
27
27
  }
28
28
  }
29
- let batches = [], keys = [...merged.keys()];
29
+ let edits = [], keys = [...merged.keys()];
30
30
  for (let i = 0, n = keys.length; i < n; i++) {
31
- let before = code, result = modify(code, file, keys[i], merged.get(keys[i]));
32
- code = result.code;
33
- if (result.edits.length > 0) {
34
- batches.push({ before, edits: result.edits });
35
- }
36
- if (i < n - 1) {
37
- file = languageService.parse(file.fileName, code);
38
- }
31
+ let result = modify(code, file, keys[i], merged.get(keys[i]));
32
+ edits.push(...result.edits);
33
+ }
34
+ if (edits.length === 0) {
35
+ return { batches: [], code };
39
36
  }
40
- return { batches, code };
37
+ return { batches: [{ before: code, edits }], code: replaceReverse(code, edits) };
41
38
  }
42
39
  function applyIntents(code, file, intents) {
43
40
  if (intents.length === 0) {
@@ -84,6 +81,9 @@ function modify(code, file, pkg, options) {
84
81
  return { code, edits: [] };
85
82
  }
86
83
  let { namespace } = options, add = options.add ? new Set(options.add) : null, found = imports.all(file, pkg);
84
+ if (namespace && !add && !options.remove && found.some(info => info.namespace === namespace)) {
85
+ return { code, edits: [] };
86
+ }
87
87
  if (found.length === 0) {
88
88
  let statements = [];
89
89
  if (namespace) {
@@ -98,7 +98,7 @@ function modify(code, file, pkg, options) {
98
98
  let newText = statements.join('\n') + '\n';
99
99
  return { code: newText + code, edits: [{ end: 0, newText, start: 0 }] };
100
100
  }
101
- let remove = options.remove ? new Set(options.remove) : null, specifiers = new Set();
101
+ let remove = options.remove ? new Set(options.remove) : null, specifiers = new Set(), nonNamespace = found.filter(info => !info.namespace), existingNamespace = found.some(info => info.namespace === namespace), defaultImport = nonNamespace.find(info => info.defaultName), target = defaultImport ?? nonNamespace[0];
102
102
  for (let i = 0, n = found.length; i < n; i++) {
103
103
  for (let [name, alias] of found[i].specifiers) {
104
104
  if (!remove || (!remove.has(name) && !remove.has(alias))) {
@@ -113,20 +113,34 @@ function modify(code, file, pkg, options) {
113
113
  }
114
114
  }
115
115
  let statements = [];
116
- if (namespace) {
116
+ if (namespace && !existingNamespace) {
117
117
  statements.push(`import * as ${namespace} from '${pkg}';`);
118
118
  }
119
- if (specifiers.size > 0) {
119
+ if (target) {
120
+ let named = specifiers.size > 0 ? `{ ${[...specifiers].sort().join(', ')} }` : '', defaultName = target.defaultName, clause = defaultName && named ? `${defaultName}, ${named}` : defaultName ?? named;
121
+ if (clause) {
122
+ statements.push(`import ${clause} from '${pkg}';`);
123
+ }
124
+ }
125
+ else if (specifiers.size > 0) {
120
126
  statements.push(`import { ${[...specifiers].sort().join(', ')} } from '${pkg}';`);
121
127
  }
122
128
  let replacements = [];
123
129
  for (let i = 0, n = found.length; i < n; i++) {
130
+ let info = found[i];
131
+ if (info.namespace) {
132
+ continue;
133
+ }
124
134
  replacements.push({
125
- end: found[i].end,
126
- newText: i === 0 ? statements.join('\n') : '',
127
- start: found[i].start
135
+ end: info.end,
136
+ newText: info === target ? statements.join('\n') : info.defaultName ? `import ${info.defaultName} from '${pkg}';` : '',
137
+ start: info.start
128
138
  });
129
139
  }
140
+ if (!target && statements.length > 0) {
141
+ let first = found[0];
142
+ replacements.push({ end: first.start, newText: statements.join('\n') + '\n', start: first.start });
143
+ }
130
144
  return { code: replaceReverse(code, replacements), edits: replacements };
131
145
  }
132
146
  function replaceReverse(code, replacements) {
@@ -150,7 +164,7 @@ function replaceReverse(code, replacements) {
150
164
  }
151
165
  const transform = (plugins, code, file, project, root, shared) => {
152
166
  if (plugins.length === 0) {
153
- return { changed: false, code, map: { generations: [] }, sourceFile: file };
167
+ return { changed: false, code, map: { generations: [] } };
154
168
  }
155
169
  uid.scope(root, file.fileName, code);
156
170
  let changed = false, currentCode = code, currentFile = file, currentProject = project, fileName = file.fileName, generations = [], last = plugins.length - 1;
@@ -199,15 +213,12 @@ const transform = (plugins, code, file, project, root, shared) => {
199
213
  if (pluginChanged) {
200
214
  changed = true;
201
215
  if (i < last) {
202
- currentProject = languageService.update(root, fileName, currentCode);
216
+ currentProject = languageService.update(project.configPath ?? languageService.findConfig(root) ?? root, fileName, currentCode);
203
217
  currentFile = currentProject.program.getSourceFile(fileName) ??
204
218
  languageService.parse(fileName, currentCode);
205
219
  }
206
- else {
207
- currentFile = languageService.parse(fileName, currentCode);
208
- }
209
220
  }
210
221
  }
211
- return { changed, code: currentCode, map: { generations }, sourceFile: currentFile };
222
+ return { changed, code: currentCode, map: { generations } };
212
223
  };
213
224
  export default { transform };
@@ -1,7 +1,9 @@
1
1
  import type { Node, SourceFile } from 'typescript/unstable/ast';
2
2
  import type { Checker } from 'typescript/unstable/sync';
3
3
  type ImportInfo = {
4
+ defaultName?: string;
4
5
  end: number;
6
+ namespace?: string;
5
7
  specifiers: Map<string, string>;
6
8
  typeOnly: Set<string>;
7
9
  start: number;
@@ -1,5 +1,5 @@
1
1
  import { SyntaxKind } from 'typescript/unstable/ast';
2
- import { isIdentifier, isImportDeclaration, isNamedImports, isStringLiteral } from 'typescript/unstable/ast/is';
2
+ import { isIdentifier, isImportDeclaration, isNamedImports, isNamespaceImport, isStringLiteral } from 'typescript/unstable/ast/is';
3
3
  import { SymbolFlags } from 'typescript/unstable/sync';
4
4
  let cache = new WeakMap();
5
5
  function fileNameMatchesPackage(fileName, pkg) {
@@ -27,7 +27,14 @@ const all = (file, pkg) => {
27
27
  }
28
28
  }
29
29
  }
30
- imports.push({ end: stmt.end, specifiers, start: stmt.getStart(file), typeOnly });
30
+ imports.push({
31
+ defaultName: stmt.importClause?.name?.text,
32
+ end: stmt.end,
33
+ namespace: bindings && isNamespaceImport(bindings) ? bindings.name.text : undefined,
34
+ specifiers,
35
+ start: stmt.getStart(file),
36
+ typeOnly
37
+ });
31
38
  }
32
39
  return imports;
33
40
  };