@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 +16 -8
- package/driver/config.go +506 -0
- package/driver/strip.go +249 -203
- package/package.json +8 -2
- package/src/index.cjs +25 -1
- package/src/index.d.ts +38 -0
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
"statements": ["debugger"],
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
},
|
|
64
|
+
{ "transform": "@ttsc/strip", "configFile": "./config/strip.config.ts" }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
59
67
|
}
|
|
60
68
|
```
|
|
61
69
|
|
package/driver/config.go
ADDED
|
@@ -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
|
-
|
|
5
|
-
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
shimast "github.com/microsoft/typescript-go/shim/ast"
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
"github.com/samchon/ttsc/packages/ttsc/driver"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
12
|
func init() {
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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
|
-
".":
|
|
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
|
-
|
|
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;
|