@ttsc/strip 0.12.3 → 0.13.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.
package/README.md CHANGED
@@ -43,19 +43,27 @@ Default behavior removes these statement patterns:
43
43
 
44
44
  Call patterns match statement-level calls such as `console.log("debug")` or `assert.equal(left, right)`. A wildcard is supported at the end of a dotted call pattern, such as `assert.*`.
45
45
 
46
- Add a direct plugin config only when the project needs a different strip list:
46
+ To customize the strip list, add a `strip.config.ts` next to your `tsconfig.json`:
47
+
48
+ ```ts
49
+ // strip.config.ts
50
+ import type { ITtscStripConfig } from "@ttsc/strip";
51
+
52
+ export default {
53
+ calls: ["console.log", "console.debug", "assert.*"],
54
+ statements: ["debugger"],
55
+ } satisfies ITtscStripConfig;
56
+ ```
57
+
58
+ `@ttsc/strip` discovers its config by walking upward from the tsconfig directory, looking for `strip.config.{ts,cts,mts,js,cjs,mjs,json}`. To point at a specific file, set `configFile` on the tsconfig entry:
47
59
 
48
60
  ```jsonc
49
61
  {
50
62
  "compilerOptions": {
51
63
  "plugins": [
52
- {
53
- "transform": "@ttsc/strip",
54
- "calls": ["console.log", "console.debug", "assert.*"],
55
- "statements": ["debugger"],
56
- },
57
- ],
58
- },
64
+ { "transform": "@ttsc/strip", "configFile": "./config/strip.config.ts" }
65
+ ]
66
+ }
59
67
  }
60
68
  ```
61
69
 
@@ -0,0 +1,506 @@
1
+ package strip
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "runtime"
12
+ "strings"
13
+ "time"
14
+ )
15
+
16
+ // configLoaderTimeout caps every `ttsx`/`node -e` subprocess that evaluates a
17
+ // user-supplied strip config. Mirrors the lint package budget: 60 s is generous
18
+ // for cold ttsx starts on CI runners and tight enough to keep user-visible
19
+ // feedback under a minute.
20
+ const configLoaderTimeout = 60 * time.Second
21
+
22
+ // stripConfigFilenames is the ordered list of candidate filenames that
23
+ // findStripConfigFile checks in each directory during upward discovery.
24
+ var stripConfigFilenames = []string{
25
+ "strip.config.ts",
26
+ "strip.config.mts",
27
+ "strip.config.cts",
28
+ "strip.config.js",
29
+ "strip.config.mjs",
30
+ "strip.config.cjs",
31
+ "strip.config.json",
32
+ }
33
+
34
+ // allowedTsconfigKeys lists the tsconfig plugin-entry keys that @ttsc/strip
35
+ // accepts. Any other key is a hard error.
36
+ var allowedTsconfigKeys = map[string]struct{}{
37
+ "configFile": {},
38
+ "enabled": {},
39
+ "name": {},
40
+ "stage": {},
41
+ "transform": {},
42
+ }
43
+
44
+ // loadStripConfigMap validates the tsconfig plugin entry and loads the strip
45
+ // configuration from either an explicit configFile or an auto-discovered
46
+ // strip.config.* file. Returns the raw config map ready for parseStrip.
47
+ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (map[string]any, error) {
48
+ // Reject any key that @ttsc/strip does not recognise. This surfaces
49
+ // stale inline keys (calls, statements) with a clear error so users
50
+ // migrate to a config file instead of silently using defaults.
51
+ for key := range pluginConfig {
52
+ if _, ok := allowedTsconfigKeys[key]; !ok {
53
+ return nil, fmt.Errorf(
54
+ "@ttsc/strip: tsconfig plugin entry contains unsupported key %q; "+
55
+ "strip configuration must be supplied via a strip.config.* file "+
56
+ "(use the \"configFile\" key to point at a custom path)",
57
+ key,
58
+ )
59
+ }
60
+ }
61
+
62
+ // Resolve the config file: explicit configFile wins over discovery.
63
+ configFilePath := ""
64
+ if rawCF, ok := pluginConfig["configFile"]; ok {
65
+ cf, ok := rawCF.(string)
66
+ if !ok || strings.TrimSpace(cf) == "" {
67
+ return nil, fmt.Errorf("@ttsc/strip: \"configFile\" must be a non-empty string path")
68
+ }
69
+ configFilePath = resolveStripConfigFilePath(cf, cwd, tsconfigPath)
70
+ } else {
71
+ discovered, err := findStripConfigFile(cwd, tsconfigPath)
72
+ if err != nil {
73
+ return nil, err
74
+ }
75
+ configFilePath = discovered
76
+ }
77
+
78
+ // No config file found → use defaults (parseStrip treats an empty map
79
+ // as "apply built-in defaults").
80
+ if configFilePath == "" {
81
+ return map[string]any{}, nil
82
+ }
83
+
84
+ raw, err := loadStripConfigFile(configFilePath)
85
+ if err != nil {
86
+ return nil, err
87
+ }
88
+ cfg, ok := raw.(map[string]any)
89
+ if !ok {
90
+ return nil, fmt.Errorf("@ttsc/strip: config file %s must export an object", configFilePath)
91
+ }
92
+ return cfg, nil
93
+ }
94
+
95
+ // findStripConfigFile walks upward from the tsconfig directory (or cwd when no
96
+ // tsconfig is set) and returns the first directory that contains exactly one
97
+ // strip.config.* file. Multiple candidates in the same directory is an error.
98
+ // Returns "" (no error) when the filesystem root is reached without a match.
99
+ func findStripConfigFile(cwd, tsconfigPath string) (string, error) {
100
+ dir := stripDiscoveryBaseDir(cwd, tsconfigPath)
101
+ for {
102
+ matches := make([]string, 0, 1)
103
+ for _, name := range stripConfigFilenames {
104
+ candidate := filepath.Join(dir, name)
105
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
106
+ matches = append(matches, candidate)
107
+ }
108
+ }
109
+ if len(matches) > 1 {
110
+ names := make([]string, 0, len(matches))
111
+ for _, m := range matches {
112
+ names = append(names, filepath.Base(m))
113
+ }
114
+ return "", fmt.Errorf(
115
+ "@ttsc/strip: multiple strip config files found in %s (%s); "+
116
+ "set \"configFile\" explicitly in the tsconfig plugin entry",
117
+ dir, strings.Join(names, ", "),
118
+ )
119
+ }
120
+ if len(matches) == 1 {
121
+ return matches[0], nil
122
+ }
123
+ parent := filepath.Dir(dir)
124
+ if parent == dir {
125
+ return "", nil
126
+ }
127
+ dir = parent
128
+ }
129
+ }
130
+
131
+ // stripDiscoveryBaseDir returns the directory from which auto-discovery walks
132
+ // upward. Prefers the tsconfig directory over cwd so nested package configs are
133
+ // found relative to the tsconfig that triggered the strip run.
134
+ func stripDiscoveryBaseDir(cwd, tsconfigPath string) string {
135
+ if tsconfigPath != "" {
136
+ resolved := tsconfigPath
137
+ if !filepath.IsAbs(resolved) {
138
+ resolved = filepath.Join(cwd, resolved)
139
+ }
140
+ return filepath.Dir(resolved)
141
+ }
142
+ return cwd
143
+ }
144
+
145
+ // resolveStripConfigFilePath resolves a user-supplied config path to an
146
+ // absolute path. Absolute paths are returned unchanged; relative paths are
147
+ // joined to the tsconfig directory (or cwd when no tsconfig is set).
148
+ func resolveStripConfigFilePath(configPath, cwd, tsconfigPath string) string {
149
+ if filepath.IsAbs(configPath) {
150
+ return configPath
151
+ }
152
+ return filepath.Join(stripDiscoveryBaseDir(cwd, tsconfigPath), configPath)
153
+ }
154
+
155
+ // loadStripConfigFile loads and deserializes a strip config file at location.
156
+ // The format is determined by extension: .json is parsed natively; .js/.cjs/.mjs
157
+ // run through a Node subprocess; .ts/.cts/.mts run through ttsx.
158
+ func loadStripConfigFile(location string) (any, error) {
159
+ ext := strings.ToLower(filepath.Ext(location))
160
+ switch ext {
161
+ case ".json":
162
+ return loadStripJSONConfigFile(location)
163
+ case ".js", ".cjs", ".mjs":
164
+ return loadStripScriptConfigFile(location)
165
+ case ".ts", ".cts", ".mts":
166
+ return loadStripTypeScriptConfigFile(location)
167
+ default:
168
+ return nil, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
169
+ }
170
+ }
171
+
172
+ // loadStripJSONConfigFile reads and JSON-parses a strip config file. A leading
173
+ // UTF-8 BOM is stripped before parsing so files saved by Windows editors are
174
+ // accepted.
175
+ func loadStripJSONConfigFile(location string) (any, error) {
176
+ body, err := os.ReadFile(location)
177
+ if err != nil {
178
+ return nil, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
179
+ }
180
+ body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
181
+ var out any
182
+ if err := json.Unmarshal(body, &out); err != nil {
183
+ return nil, fmt.Errorf("@ttsc/strip: parse config file %s: %w", location, err)
184
+ }
185
+ return out, nil
186
+ }
187
+
188
+ // stripScriptLoaderSource is the inline Node.js script used by
189
+ // loadStripScriptConfigFile to evaluate a .js/.cjs/.mjs strip config and
190
+ // serialize the result to stdout as JSON.
191
+ const stripScriptLoaderSource = `
192
+ const { pathToFileURL } = require("node:url");
193
+
194
+ (async () => {
195
+ const mod = await import(pathToFileURL(process.argv[1]).href);
196
+ let current = mod;
197
+ for (let i = 0; i < 8; i++) {
198
+ if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, "default")) {
199
+ current = current.default;
200
+ continue;
201
+ }
202
+ break;
203
+ }
204
+ const value = typeof current === "function" ? await current() : current;
205
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
206
+ throw new Error("strip config file must export an object");
207
+ }
208
+ process.stdout.write(JSON.stringify(value));
209
+ })().catch((error) => {
210
+ process.stderr.write(error && error.stack ? error.stack : String(error));
211
+ process.exit(1);
212
+ });
213
+ `
214
+
215
+ // loadStripScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
216
+ // Node subprocess that dynamic-imports the file, resolves the default export,
217
+ // and serializes the result as JSON to stdout.
218
+ func loadStripScriptConfigFile(location string) (any, error) {
219
+ node := os.Getenv("TTSC_NODE_BINARY")
220
+ if node == "" {
221
+ node = "node"
222
+ }
223
+ ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
224
+ defer cancel()
225
+ cmd := exec.CommandContext(ctx, node, "-e", stripScriptLoaderSource, location)
226
+ cmd.Env = stripNodeConfigLoaderEnv(location)
227
+ output, err := cmd.Output()
228
+ if err != nil {
229
+ if ctx.Err() == context.DeadlineExceeded {
230
+ return nil, fmt.Errorf("@ttsc/strip: load config file %s: timed out after %s", location, configLoaderTimeout)
231
+ }
232
+ stderr := ""
233
+ if exit, ok := err.(*exec.ExitError); ok {
234
+ stderr = strings.TrimSpace(string(exit.Stderr))
235
+ }
236
+ if stderr != "" {
237
+ return nil, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, stderr)
238
+ }
239
+ return nil, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
240
+ }
241
+ var out any
242
+ if err := json.Unmarshal(output, &out); err != nil {
243
+ return nil, fmt.Errorf("@ttsc/strip: parse config file %s output: %w", location, err)
244
+ }
245
+ return out, nil
246
+ }
247
+
248
+ // stripTypeScriptLoaderSource returns the TypeScript source of the ephemeral
249
+ // loader script that ttsx executes to evaluate a TypeScript strip config file.
250
+ // importLiteral must be a JSON-encoded relative import path (e.g.
251
+ // `"./strip.config.ts"`) produced by json.Marshal.
252
+ func stripTypeScriptLoaderSource(importLiteral string) string {
253
+ return fmt.Sprintf(`import * as importedConfig from %s;
254
+
255
+ declare const process: {
256
+ stdout: { write(value: string): void };
257
+ stderr: { write(value: string): void };
258
+ exit(code?: number): never;
259
+ };
260
+
261
+ try {
262
+ let current: unknown = importedConfig;
263
+ for (let i = 0; i < 8; i++) {
264
+ if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current as Record<string, unknown>, "default")) {
265
+ current = (current as Record<string, unknown>).default;
266
+ continue;
267
+ }
268
+ break;
269
+ }
270
+ if (typeof current === "function") {
271
+ current = await (current as () => unknown | Promise<unknown>)();
272
+ }
273
+ if (current === null || typeof current !== "object" || Array.isArray(current)) {
274
+ throw new Error("strip config file must export an object");
275
+ }
276
+ process.stdout.write(JSON.stringify(current));
277
+ } catch (error) {
278
+ process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
279
+ process.exit(1);
280
+ }
281
+ `, importLiteral)
282
+ }
283
+
284
+ // loadStripTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
285
+ // an ephemeral loader script and tsconfig into a temp directory, symlinking the
286
+ // nearest node_modules, then running ttsx with a configLoaderTimeout deadline.
287
+ //
288
+ // The ttsx build runs with `--no-plugins`: the loader only needs to
289
+ // type-check and execute the strip config file, so loading the host
290
+ // project's transform/check plugins would be wasteful and could fail the
291
+ // build against this deliberately lenient loader tsconfig.
292
+ func loadStripTypeScriptConfigFile(location string) (any, error) {
293
+ tempDir, err := os.MkdirTemp("", "ttsc-strip-config-")
294
+ if err != nil {
295
+ return nil, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
296
+ }
297
+ defer os.RemoveAll(tempDir)
298
+
299
+ if err := stripLinkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
300
+ return nil, err
301
+ }
302
+
303
+ loader := filepath.Join(tempDir, "loader.mts")
304
+ tsconfig := filepath.Join(tempDir, "tsconfig.json")
305
+ importSpecifier, err := stripRelativeImportSpecifier(tempDir, location)
306
+ if err != nil {
307
+ return nil, err
308
+ }
309
+ importLiteral, err := json.Marshal(importSpecifier)
310
+ if err != nil {
311
+ return nil, fmt.Errorf("@ttsc/strip: encode config import %s: %w", location, err)
312
+ }
313
+ if err := os.WriteFile(loader, []byte(stripTypeScriptLoaderSource(string(importLiteral))), 0o644); err != nil {
314
+ return nil, fmt.Errorf("@ttsc/strip: write config loader: %w", err)
315
+ }
316
+ if err := os.WriteFile(tsconfig, []byte(stripTypeScriptLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
317
+ return nil, fmt.Errorf("@ttsc/strip: write config loader tsconfig: %w", err)
318
+ }
319
+
320
+ args := []string{
321
+ "--project", tsconfig,
322
+ "--cwd", tempDir,
323
+ "--cache-dir", filepath.Join(tempDir, "cache"),
324
+ "--no-plugins",
325
+ }
326
+ if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
327
+ args = append(args, "--binary", tsgo)
328
+ }
329
+ args = append(args, loader)
330
+
331
+ ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
332
+ defer cancel()
333
+ cmd := stripTtsxCommandContext(ctx, args...)
334
+ cmd.Env = stripNodeConfigLoaderEnv(location)
335
+ output, err := cmd.Output()
336
+ if err != nil {
337
+ if ctx.Err() == context.DeadlineExceeded {
338
+ return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: timed out after %s", location, configLoaderTimeout)
339
+ }
340
+ stderr := ""
341
+ if exit, ok := err.(*exec.ExitError); ok {
342
+ stderr = strings.TrimSpace(string(exit.Stderr))
343
+ }
344
+ if stderr != "" {
345
+ return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, stderr)
346
+ }
347
+ return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
348
+ }
349
+ var out any
350
+ if err := json.Unmarshal(output, &out); err != nil {
351
+ return nil, fmt.Errorf("@ttsc/strip: parse TypeScript config file %s output: %w", location, err)
352
+ }
353
+ return out, nil
354
+ }
355
+
356
+ // stripTypeScriptLoaderTsconfig generates the JSON content of the ephemeral
357
+ // tsconfig used by the loader script.
358
+ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
359
+ content := map[string]any{
360
+ "compilerOptions": map[string]any{
361
+ "allowImportingTsExtensions": true,
362
+ "allowJs": true,
363
+ "checkJs": false,
364
+ "module": "ESNext",
365
+ "moduleResolution": "bundler",
366
+ "noImplicitAny": false,
367
+ "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
368
+ "rewriteRelativeImportExtensions": true,
369
+ "rootDir": "/",
370
+ "skipLibCheck": true,
371
+ "strict": false,
372
+ "target": "ES2022",
373
+ },
374
+ "files": []string{
375
+ filepath.ToSlash(loader),
376
+ filepath.ToSlash(location),
377
+ },
378
+ }
379
+ body, err := json.MarshalIndent(content, "", " ")
380
+ if err != nil {
381
+ panic(err)
382
+ }
383
+ return string(body)
384
+ }
385
+
386
+ // stripTtsxCommandContext returns an exec.Cmd that runs ttsx with the given
387
+ // arguments, routing through node when the resolved binary is a script file.
388
+ func stripTtsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
389
+ ttsx := os.Getenv("TTSC_TTSX_BINARY")
390
+ if ttsx == "" {
391
+ ttsx = "ttsx"
392
+ }
393
+ if stripShouldRunThroughNode(ttsx) {
394
+ node := os.Getenv("TTSC_NODE_BINARY")
395
+ if node == "" {
396
+ node = "node"
397
+ }
398
+ return exec.CommandContext(ctx, node, append([]string{ttsx}, args...)...)
399
+ }
400
+ return exec.CommandContext(ctx, ttsx, args...)
401
+ }
402
+
403
+ // stripShouldRunThroughNode reports whether the resolved ttsx binary is a
404
+ // script (JS or TS extension) that must be executed via node.
405
+ func stripShouldRunThroughNode(binary string) bool {
406
+ switch strings.ToLower(filepath.Ext(binary)) {
407
+ case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
408
+ return true
409
+ default:
410
+ return false
411
+ }
412
+ }
413
+
414
+ // stripNodeConfigLoaderEnv builds the environment for a Node.js config-loader
415
+ // subprocess. Prepends the nearest node_modules to NODE_PATH so imports in
416
+ // .js/.cjs/.mjs config files resolve correctly.
417
+ func stripNodeConfigLoaderEnv(location string) []string {
418
+ env := os.Environ()
419
+ parts := make([]string, 0, 2)
420
+ if nodeModules := stripFindNearestNodeModules(filepath.Dir(location)); nodeModules != "" {
421
+ parts = append(parts, nodeModules)
422
+ }
423
+ if existing := os.Getenv("NODE_PATH"); existing != "" {
424
+ parts = append(parts, existing)
425
+ }
426
+ if len(parts) == 0 {
427
+ return env
428
+ }
429
+ return stripSetEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
430
+ }
431
+
432
+ // stripFindNearestNodeModules walks upward from start and returns the first
433
+ // node_modules directory found, or "" when the filesystem root is reached.
434
+ func stripFindNearestNodeModules(start string) string {
435
+ dir := filepath.Clean(start)
436
+ for {
437
+ candidate := filepath.Join(dir, "node_modules")
438
+ if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
439
+ return candidate
440
+ }
441
+ parent := filepath.Dir(dir)
442
+ if parent == dir {
443
+ return ""
444
+ }
445
+ dir = parent
446
+ }
447
+ }
448
+
449
+ // stripLinkNearestNodeModules creates a node_modules symlink (or Windows
450
+ // junction) inside tempDir pointing at the nearest node_modules upward from
451
+ // sourceDir. No-op when no node_modules is found.
452
+ func stripLinkNearestNodeModules(tempDir, sourceDir string) error {
453
+ nodeModules := stripFindNearestNodeModules(sourceDir)
454
+ if nodeModules == "" {
455
+ return nil
456
+ }
457
+ link := filepath.Join(tempDir, "node_modules")
458
+ err := os.Symlink(nodeModules, link)
459
+ if err == nil {
460
+ return nil
461
+ }
462
+ if runtime.GOOS == "windows" {
463
+ jerr := stripCreateWindowsJunction(link, nodeModules)
464
+ if jerr == nil {
465
+ return nil
466
+ }
467
+ err = fmt.Errorf("%w (junction fallback: %v)", err, jerr)
468
+ }
469
+ return fmt.Errorf("@ttsc/strip: link config node_modules %s: %w", nodeModules, err)
470
+ }
471
+
472
+ // stripCreateWindowsJunction creates a directory junction on Windows.
473
+ func stripCreateWindowsJunction(link, target string) error {
474
+ cmd := exec.Command("cmd", "/c", "mklink", "/J", link, target)
475
+ if out, err := cmd.CombinedOutput(); err != nil {
476
+ return fmt.Errorf("mklink /J failed: %v: %s", err, strings.TrimSpace(string(out)))
477
+ }
478
+ return nil
479
+ }
480
+
481
+ // stripRelativeImportSpecifier computes the ESM import specifier for location
482
+ // relative to fromDir, always prefixed with "./" or "../".
483
+ func stripRelativeImportSpecifier(fromDir, location string) (string, error) {
484
+ relative, err := filepath.Rel(fromDir, location)
485
+ if err != nil {
486
+ return "", fmt.Errorf("@ttsc/strip: resolve relative config import %s: %w", location, err)
487
+ }
488
+ relative = filepath.ToSlash(relative)
489
+ if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
490
+ return relative, nil
491
+ }
492
+ return "./" + relative, nil
493
+ }
494
+
495
+ // stripSetEnv updates an existing key=value entry in env (in-place) or appends
496
+ // a new one.
497
+ func stripSetEnv(env []string, key, value string) []string {
498
+ prefix := key + "="
499
+ for i, entry := range env {
500
+ if strings.HasPrefix(entry, prefix) {
501
+ env[i] = prefix + value
502
+ return env
503
+ }
504
+ }
505
+ return append(env, prefix+value)
506
+ }
package/driver/strip.go CHANGED
@@ -1,268 +1,314 @@
1
1
  package strip
2
2
 
3
3
  import (
4
- "fmt"
5
- "strings"
4
+ "fmt"
5
+ "strings"
6
6
 
7
- shimast "github.com/microsoft/typescript-go/shim/ast"
7
+ shimast "github.com/microsoft/typescript-go/shim/ast"
8
8
 
9
- "github.com/samchon/ttsc/packages/ttsc/driver"
9
+ "github.com/samchon/ttsc/packages/ttsc/driver"
10
10
  )
11
11
 
12
12
  func init() {
13
- driver.RegisterPlugin(plugin{})
13
+ driver.RegisterPlugin(plugin{})
14
14
  }
15
15
 
16
+ // plugin implements driver.ProgramPlugin for @ttsc/strip.
16
17
  type plugin struct{}
17
18
 
19
+ // ApplyProgram strips configured call expressions and debugger statements from
20
+ // every source file in the program.
18
21
  func (plugin) ApplyProgram(prog *driver.Program, ctx driver.PluginContext) error {
19
- rewriter, err := parseStrip(ctx.Entry.Config)
20
- if err != nil {
21
- return err
22
- }
23
- for _, file := range prog.SourceFiles() {
24
- rewriter.apply(file)
25
- }
26
- return nil
22
+ config, err := loadStripConfigMap(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
23
+ if err != nil {
24
+ return err
25
+ }
26
+ rewriter, err := parseStrip(config)
27
+ if err != nil {
28
+ return err
29
+ }
30
+ for _, file := range prog.SourceFiles() {
31
+ rewriter.apply(file)
32
+ }
33
+ return nil
27
34
  }
28
35
 
36
+ // stripRewriter holds the resolved strip configuration for a single build.
29
37
  type stripRewriter struct {
30
- calls []callPattern
31
- stripDebugger bool
38
+ calls []callPattern
39
+ stripDebugger bool
32
40
  }
33
41
 
42
+ // callPattern represents a parsed call-expression stripping rule such as
43
+ // "console.log" (exact) or "assert.*" (wildcard prefix).
34
44
  type callPattern struct {
35
- parts []string
36
- wildcard bool
45
+ parts []string
46
+ wildcard bool
37
47
  }
38
48
 
49
+ // parseStrip builds a stripRewriter from the plugin config map. When neither
50
+ // "calls" nor "statements" is present the default configuration is applied:
51
+ // strip console.log, console.debug, assert.*, and debugger statements.
39
52
  func parseStrip(config map[string]any) (*stripRewriter, error) {
40
- _, hasCalls := config["calls"]
41
- _, hasStatements := config["statements"]
42
- if !hasCalls && !hasStatements {
43
- config = map[string]any{
44
- "calls": []any{"console.log", "console.debug", "assert.*"},
45
- "statements": []any{"debugger"},
46
- }
47
- }
48
- calls, err := stringArrayConfig(config, "calls")
49
- if err != nil {
50
- return nil, fmt.Errorf("@ttsc/strip: %w", err)
51
- }
52
- statements, err := stringArrayConfig(config, "statements")
53
- if err != nil {
54
- return nil, fmt.Errorf("@ttsc/strip: %w", err)
55
- }
56
- out := &stripRewriter{}
57
- for _, call := range calls {
58
- pattern, err := parseCallPattern(call)
59
- if err != nil {
60
- return nil, fmt.Errorf("@ttsc/strip: %w", err)
61
- }
62
- out.calls = append(out.calls, pattern)
63
- }
64
- for _, statement := range statements {
65
- switch statement {
66
- case "debugger":
67
- out.stripDebugger = true
68
- default:
69
- return nil, fmt.Errorf("@ttsc/strip: unsupported statement pattern %q", statement)
70
- }
71
- }
72
- return out, nil
53
+ _, hasCalls := config["calls"]
54
+ _, hasStatements := config["statements"]
55
+ if !hasCalls && !hasStatements {
56
+ config = map[string]any{
57
+ "calls": []any{"console.log", "console.debug", "assert.*"},
58
+ "statements": []any{"debugger"},
59
+ }
60
+ }
61
+ calls, err := stringArrayConfig(config, "calls")
62
+ if err != nil {
63
+ return nil, fmt.Errorf("@ttsc/strip: %w", err)
64
+ }
65
+ statements, err := stringArrayConfig(config, "statements")
66
+ if err != nil {
67
+ return nil, fmt.Errorf("@ttsc/strip: %w", err)
68
+ }
69
+ out := &stripRewriter{}
70
+ for _, call := range calls {
71
+ pattern, err := parseCallPattern(call)
72
+ if err != nil {
73
+ return nil, fmt.Errorf("@ttsc/strip: %w", err)
74
+ }
75
+ out.calls = append(out.calls, pattern)
76
+ }
77
+ for _, statement := range statements {
78
+ switch statement {
79
+ case "debugger":
80
+ out.stripDebugger = true
81
+ default:
82
+ return nil, fmt.Errorf("@ttsc/strip: unsupported statement pattern %q", statement)
83
+ }
84
+ }
85
+ return out, nil
73
86
  }
74
87
 
88
+ // apply removes matching statements from file's top-level statement list.
75
89
  func (s *stripRewriter) apply(file *shimast.SourceFile) {
76
- if s == nil || file == nil || (len(s.calls) == 0 && !s.stripDebugger) {
77
- return
78
- }
79
- filterStatements(file.Statements, s)
90
+ if s == nil || file == nil || (len(s.calls) == 0 && !s.stripDebugger) {
91
+ return
92
+ }
93
+ filterStatements(file.Statements, s)
80
94
  }
81
95
 
96
+ // filterStatements removes stripped statements from list in-place, preserving
97
+ // order. Children of retained statements are recursively filtered.
82
98
  func filterStatements(list *shimast.NodeList, strip *stripRewriter) {
83
- if list == nil || len(list.Nodes) == 0 {
84
- return
85
- }
86
- out := make([]*shimast.Node, 0, len(list.Nodes))
87
- for _, stmt := range list.Nodes {
88
- if shouldStripStatement(stmt, strip) {
89
- continue
90
- }
91
- filterChildStatements(stmt, strip)
92
- out = append(out, stmt)
93
- }
94
- list.Nodes = out
99
+ if list == nil || len(list.Nodes) == 0 {
100
+ return
101
+ }
102
+ out := make([]*shimast.Node, 0, len(list.Nodes))
103
+ for _, stmt := range list.Nodes {
104
+ if shouldStripStatement(stmt, strip) {
105
+ continue
106
+ }
107
+ filterChildStatements(stmt, strip)
108
+ out = append(out, stmt)
109
+ }
110
+ list.Nodes = out
95
111
  }
96
112
 
113
+ // filterChildStatements recurses into node's children, filtering embedded
114
+ // single-statement bodies (if, while, for, etc.) and nested statement lists.
97
115
  func filterChildStatements(node *shimast.Node, strip *stripRewriter) {
98
- if node == nil {
99
- return
100
- }
101
- filterEmbeddedStatements(node, strip)
102
- if node.CanHaveStatements() {
103
- filterStatements(node.StatementList(), strip)
104
- }
105
- node.ForEachChild(func(child *shimast.Node) bool {
106
- filterChildStatements(child, strip)
107
- return false
108
- })
116
+ if node == nil {
117
+ return
118
+ }
119
+ filterEmbeddedStatements(node, strip)
120
+ if node.CanHaveStatements() {
121
+ filterStatements(node.StatementList(), strip)
122
+ }
123
+ node.ForEachChild(func(child *shimast.Node) bool {
124
+ filterChildStatements(child, strip)
125
+ return false
126
+ })
109
127
  }
110
128
 
129
+ // filterEmbeddedStatements handles statement nodes that embed a single child
130
+ // statement (if/else, do, while, for, with, labeled). A stripped child is
131
+ // replaced with an empty synthesized statement to preserve the AST shape.
111
132
  func filterEmbeddedStatements(node *shimast.Node, strip *stripRewriter) {
112
- switch node.Kind {
113
- case shimast.KindIfStatement:
114
- stmt := node.AsIfStatement()
115
- stmt.ThenStatement = filterEmbeddedStatement(stmt.ThenStatement, strip)
116
- stmt.ElseStatement = filterEmbeddedStatement(stmt.ElseStatement, strip)
117
- case shimast.KindDoStatement:
118
- stmt := node.AsDoStatement()
119
- stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
120
- case shimast.KindWhileStatement:
121
- stmt := node.AsWhileStatement()
122
- stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
123
- case shimast.KindForStatement:
124
- stmt := node.AsForStatement()
125
- stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
126
- case shimast.KindForInStatement, shimast.KindForOfStatement:
127
- stmt := node.AsForInOrOfStatement()
128
- stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
129
- case shimast.KindWithStatement:
130
- stmt := node.AsWithStatement()
131
- stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
132
- case shimast.KindLabeledStatement:
133
- stmt := node.AsLabeledStatement()
134
- stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
135
- }
133
+ switch node.Kind {
134
+ case shimast.KindIfStatement:
135
+ stmt := node.AsIfStatement()
136
+ stmt.ThenStatement = filterEmbeddedStatement(stmt.ThenStatement, strip)
137
+ stmt.ElseStatement = filterEmbeddedStatement(stmt.ElseStatement, strip)
138
+ case shimast.KindDoStatement:
139
+ stmt := node.AsDoStatement()
140
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
141
+ case shimast.KindWhileStatement:
142
+ stmt := node.AsWhileStatement()
143
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
144
+ case shimast.KindForStatement:
145
+ stmt := node.AsForStatement()
146
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
147
+ case shimast.KindForInStatement, shimast.KindForOfStatement:
148
+ stmt := node.AsForInOrOfStatement()
149
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
150
+ case shimast.KindWithStatement:
151
+ stmt := node.AsWithStatement()
152
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
153
+ case shimast.KindLabeledStatement:
154
+ stmt := node.AsLabeledStatement()
155
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
156
+ }
136
157
  }
137
158
 
159
+ // filterEmbeddedStatement strips or recurses into a single embedded statement.
160
+ // Returns an empty synthesized statement when stmt is to be stripped, preserving
161
+ // the original source location for downstream source-map accuracy.
138
162
  func filterEmbeddedStatement(stmt *shimast.Statement, strip *stripRewriter) *shimast.Statement {
139
- if stmt == nil {
140
- return nil
141
- }
142
- if shouldStripStatement(stmt, strip) {
143
- return emptyStatement(stmt)
144
- }
145
- filterChildStatements(stmt, strip)
146
- return stmt
163
+ if stmt == nil {
164
+ return nil
165
+ }
166
+ if shouldStripStatement(stmt, strip) {
167
+ return emptyStatement(stmt)
168
+ }
169
+ filterChildStatements(stmt, strip)
170
+ return stmt
147
171
  }
148
172
 
173
+ // emptyStatement creates a synthesized empty statement (";") that inherits
174
+ // original's source location, used as a no-op placeholder after stripping.
149
175
  func emptyStatement(original *shimast.Node) *shimast.Statement {
150
- empty := shimast.NewNodeFactory(shimast.NodeFactoryHooks{}).NewEmptyStatement()
151
- empty.Flags |= shimast.NodeFlagsSynthesized
152
- if original != nil {
153
- empty.Loc = original.Loc
154
- }
155
- return empty
176
+ empty := shimast.NewNodeFactory(shimast.NodeFactoryHooks{}).NewEmptyStatement()
177
+ empty.Flags |= shimast.NodeFlagsSynthesized
178
+ if original != nil {
179
+ empty.Loc = original.Loc
180
+ }
181
+ return empty
156
182
  }
157
183
 
184
+ // shouldStripStatement reports whether node should be removed based on the
185
+ // current strip configuration. Only debugger and expression statements are
186
+ // candidates; all other statement kinds are retained.
158
187
  func shouldStripStatement(node *shimast.Node, strip *stripRewriter) bool {
159
- if node == nil {
160
- return false
161
- }
162
- switch node.Kind {
163
- case shimast.KindDebuggerStatement:
164
- return strip.stripDebugger
165
- case shimast.KindExpressionStatement:
166
- expr := node.AsExpressionStatement().Expression
167
- name, ok := callExpressionName(expr)
168
- return ok && strip.matchesCall(name)
169
- default:
170
- return false
171
- }
188
+ if node == nil {
189
+ return false
190
+ }
191
+ switch node.Kind {
192
+ case shimast.KindDebuggerStatement:
193
+ return strip.stripDebugger
194
+ case shimast.KindExpressionStatement:
195
+ expr := node.AsExpressionStatement().Expression
196
+ name, ok := callExpressionName(expr)
197
+ return ok && strip.matchesCall(name)
198
+ default:
199
+ return false
200
+ }
172
201
  }
173
202
 
203
+ // matchesCall reports whether name matches any of the configured call patterns.
174
204
  func (s *stripRewriter) matchesCall(name string) bool {
175
- for _, pattern := range s.calls {
176
- if pattern.matches(name) {
177
- return true
178
- }
179
- }
180
- return false
205
+ for _, pattern := range s.calls {
206
+ if pattern.matches(name) {
207
+ return true
208
+ }
209
+ }
210
+ return false
181
211
  }
182
212
 
213
+ // parseCallPattern parses a dot-separated call pattern string such as
214
+ // "console.log" or "assert.*". A wildcard ("*") is only allowed as the
215
+ // final segment. Empty segments are rejected.
183
216
  func parseCallPattern(text string) (callPattern, error) {
184
- parts := strings.Split(text, ".")
185
- for i, part := range parts {
186
- if part == "" {
187
- return callPattern{}, fmt.Errorf("invalid call pattern %q", text)
188
- }
189
- if part == "*" && i != len(parts)-1 {
190
- return callPattern{}, fmt.Errorf("wildcard is only supported at the end of call pattern %q", text)
191
- }
192
- }
193
- wildcard := parts[len(parts)-1] == "*"
194
- if wildcard {
195
- parts = parts[:len(parts)-1]
196
- }
197
- return callPattern{parts: parts, wildcard: wildcard}, nil
217
+ parts := strings.Split(text, ".")
218
+ for i, part := range parts {
219
+ if part == "" {
220
+ return callPattern{}, fmt.Errorf("invalid call pattern %q", text)
221
+ }
222
+ if part == "*" && i != len(parts)-1 {
223
+ return callPattern{}, fmt.Errorf("wildcard is only supported at the end of call pattern %q", text)
224
+ }
225
+ }
226
+ wildcard := parts[len(parts)-1] == "*"
227
+ if wildcard {
228
+ parts = parts[:len(parts)-1]
229
+ }
230
+ return callPattern{parts: parts, wildcard: wildcard}, nil
198
231
  }
199
232
 
233
+ // matches reports whether a dotted call name (e.g. "console.log") matches
234
+ // the pattern. Wildcard patterns require at least one extra segment beyond
235
+ // the pattern prefix.
200
236
  func (p callPattern) matches(name string) bool {
201
- parts := strings.Split(name, ".")
202
- if p.wildcard {
203
- if len(parts) <= len(p.parts) {
204
- return false
205
- }
206
- return equalStringSlices(parts[:len(p.parts)], p.parts)
207
- }
208
- return equalStringSlices(parts, p.parts)
237
+ parts := strings.Split(name, ".")
238
+ if p.wildcard {
239
+ if len(parts) <= len(p.parts) {
240
+ return false
241
+ }
242
+ return equalStringSlices(parts[:len(p.parts)], p.parts)
243
+ }
244
+ return equalStringSlices(parts, p.parts)
209
245
  }
210
246
 
247
+ // callExpressionName extracts the dotted callee name from a call expression
248
+ // node, e.g. "console.log" from `console.log(...)`. Returns ("", false) when
249
+ // expr is not a call expression or the callee is not a dotted identifier chain.
211
250
  func callExpressionName(expr *shimast.Node) (string, bool) {
212
- if expr == nil || expr.Kind != shimast.KindCallExpression {
213
- return "", false
214
- }
215
- call := expr.AsCallExpression()
216
- return dottedName(call.Expression)
251
+ if expr == nil || expr.Kind != shimast.KindCallExpression {
252
+ return "", false
253
+ }
254
+ call := expr.AsCallExpression()
255
+ return dottedName(call.Expression)
217
256
  }
218
257
 
258
+ // dottedName recursively extracts a dot-joined identifier chain from an
259
+ // expression node. Returns ("", false) for any non-identifier, non-property-
260
+ // access node.
219
261
  func dottedName(expr *shimast.Node) (string, bool) {
220
- if expr == nil {
221
- return "", false
222
- }
223
- switch expr.Kind {
224
- case shimast.KindIdentifier:
225
- return expr.Text(), true
226
- case shimast.KindPropertyAccessExpression:
227
- prop := expr.AsPropertyAccessExpression()
228
- left, ok := dottedName(prop.Expression)
229
- if !ok || prop.Name() == nil {
230
- return "", false
231
- }
232
- return left + "." + prop.Name().Text(), true
233
- default:
234
- return "", false
235
- }
262
+ if expr == nil {
263
+ return "", false
264
+ }
265
+ switch expr.Kind {
266
+ case shimast.KindIdentifier:
267
+ return expr.Text(), true
268
+ case shimast.KindPropertyAccessExpression:
269
+ prop := expr.AsPropertyAccessExpression()
270
+ left, ok := dottedName(prop.Expression)
271
+ if !ok || prop.Name() == nil {
272
+ return "", false
273
+ }
274
+ return left + "." + prop.Name().Text(), true
275
+ default:
276
+ return "", false
277
+ }
236
278
  }
237
279
 
280
+ // stringArrayConfig reads a string array from config[key]. Returns nil when the
281
+ // key is absent. Returns an error when the value is not an array of non-empty strings.
238
282
  func stringArrayConfig(config map[string]any, key string) ([]string, error) {
239
- raw, ok := config[key]
240
- if !ok || raw == nil {
241
- return nil, nil
242
- }
243
- values, ok := raw.([]any)
244
- if !ok {
245
- return nil, fmt.Errorf("%q must be an array of strings", key)
246
- }
247
- out := make([]string, 0, len(values))
248
- for i, value := range values {
249
- text, ok := value.(string)
250
- if !ok || strings.TrimSpace(text) == "" {
251
- return nil, fmt.Errorf("%q[%d] must be a non-empty string", key, i)
252
- }
253
- out = append(out, text)
254
- }
255
- return out, nil
283
+ raw, ok := config[key]
284
+ if !ok || raw == nil {
285
+ return nil, nil
286
+ }
287
+ values, ok := raw.([]any)
288
+ if !ok {
289
+ return nil, fmt.Errorf("%q must be an array of strings", key)
290
+ }
291
+ out := make([]string, 0, len(values))
292
+ for i, value := range values {
293
+ text, ok := value.(string)
294
+ if !ok || strings.TrimSpace(text) == "" {
295
+ return nil, fmt.Errorf("%q[%d] must be a non-empty string", key, i)
296
+ }
297
+ out = append(out, text)
298
+ }
299
+ return out, nil
256
300
  }
257
301
 
302
+ // equalStringSlices reports whether left and right contain the same strings in
303
+ // the same order.
258
304
  func equalStringSlices(left, right []string) bool {
259
- if len(left) != len(right) {
260
- return false
261
- }
262
- for i := range left {
263
- if left[i] != right[i] {
264
- return false
265
- }
266
- }
267
- return true
305
+ if len(left) != len(right) {
306
+ return false
307
+ }
308
+ for i := range left {
309
+ if left[i] != right[i] {
310
+ return false
311
+ }
312
+ }
313
+ return true
268
314
  }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@ttsc/strip",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "First-party ttsc plugin that removes configured calls and statements from emitted JavaScript.",
5
5
  "main": "src/index.cjs",
6
+ "types": "src/index.d.ts",
6
7
  "exports": {
7
- ".": "./src/index.cjs",
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "default": "./src/index.cjs"
11
+ },
8
12
  "./package.json": "./package.json"
9
13
  },
10
14
  "keywords": [
@@ -21,8 +25,10 @@
21
25
  },
22
26
  "files": [
23
27
  "README.md",
28
+ "driver/config.go",
24
29
  "driver/strip.go",
25
30
  "src/index.cjs",
31
+ "src/index.d.ts",
26
32
  "go.mod",
27
33
  "plugin/main.go",
28
34
  "plugin/strip.go"
package/src/index.cjs CHANGED
@@ -3,7 +3,31 @@
3
3
 
4
4
  const path = require("node:path");
5
5
 
6
- module.exports = function createTtscStrip() {
6
+ // Keys from the tsconfig plugin entry that @ttsc/strip accepts. All other keys
7
+ // are rejected so that stale inline options (calls, statements) surface as a
8
+ // clear error rather than silently falling back to defaults.
9
+ const ALLOWED_TSCONFIG_KEYS = new Set([
10
+ "configFile",
11
+ "enabled",
12
+ "name",
13
+ "stage",
14
+ "transform",
15
+ ]);
16
+
17
+ module.exports = function createTtscStrip(context) {
18
+ const plugin =
19
+ context && typeof context === "object" && context.plugin != null
20
+ ? context.plugin
21
+ : {};
22
+ for (const key of Object.keys(plugin)) {
23
+ if (!ALLOWED_TSCONFIG_KEYS.has(key)) {
24
+ throw new Error(
25
+ `@ttsc/strip: tsconfig plugin entry contains unsupported key ${JSON.stringify(key)}; ` +
26
+ `strip configuration must be supplied via a strip.config.* file ` +
27
+ `(use the "configFile" key to point at a custom path)`,
28
+ );
29
+ }
30
+ }
7
31
  return {
8
32
  name: "@ttsc/strip",
9
33
  source: path.resolve(__dirname, "..", "driver"),
package/src/index.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Plugin descriptor factory consumed by ttsc's package discovery.
3
+ *
4
+ * `@ttsc/strip` is configured through a `strip.config.*` file, never through
5
+ * the factory context — the factory only returns the native descriptor.
6
+ *
7
+ * @internal
8
+ */
9
+ declare function createTtscStrip(context: unknown): {
10
+ name: string;
11
+ source: string;
12
+ stage: "transform";
13
+ };
14
+
15
+ declare namespace createTtscStrip {
16
+ /**
17
+ * Standalone `strip.config.{ts,cts,mts,js,cjs,mjs,json}` file shape consumed
18
+ * by `@ttsc/strip`.
19
+ *
20
+ * Both keys are optional. The built-in defaults (`calls: ["console.log",
21
+ * "console.debug", "assert.*"]`, `statements: ["debugger"]`) apply only when
22
+ * *both* keys are omitted; declaring either key replaces both defaults with
23
+ * exactly what the file lists.
24
+ */
25
+ export interface ITtscStripConfig {
26
+ /**
27
+ * Statement-level call patterns to remove, written as dotted names. A
28
+ * trailing `.*` wildcard matches any final property — e.g. `"assert.*"`
29
+ * matches `assert.equal`, `assert.deepStrictEqual`, …
30
+ */
31
+ calls?: readonly string[];
32
+
33
+ /** Bare statement kinds to remove. Currently only `"debugger"` is supported. */
34
+ statements?: readonly string[];
35
+ }
36
+ }
37
+
38
+ export = createTtscStrip;