@ttsc/strip 0.26.2 → 0.28.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/driver/config.go CHANGED
@@ -3,6 +3,7 @@ package strip
3
3
  import (
4
4
  "bytes"
5
5
  "context"
6
+ "crypto/sha256"
6
7
  "encoding/json"
7
8
  "fmt"
8
9
  "os"
@@ -41,6 +42,14 @@ var allowedTsconfigKeys = map[string]struct{}{
41
42
  // configuration from either an explicit configFile or an auto-discovered
42
43
  // strip.config.* file. Returns the raw config map ready for parseStrip.
43
44
  func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (map[string]any, error) {
45
+ return loadStripConfigMapWithReporter(pluginConfig, cwd, tsconfigPath, nil)
46
+ }
47
+
48
+ func loadStripConfigMapWithReporter(pluginConfig map[string]any, cwd, tsconfigPath string, reporter func(string)) (map[string]any, error) {
49
+ return loadStripConfigMapWithReporters(pluginConfig, cwd, tsconfigPath, reporter, nil, nil)
50
+ }
51
+
52
+ func loadStripConfigMapWithReporters(pluginConfig map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter, realpathReporter func(string, *string)) (map[string]any, error) {
44
53
  // Reject any key that @ttsc/strip does not recognise. This surfaces
45
54
  // stale inline keys (calls, statements) with a clear error so users
46
55
  // migrate to a config file instead of silently using defaults.
@@ -68,7 +77,12 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
68
77
  }
69
78
  configFilePath = resolveStripConfigFilePath(cf, cwd, tsconfigPath)
70
79
  } else {
71
- discovered, err := findStripConfigFile(cwd, tsconfigPath)
80
+ discovered, probed, err := findStripConfigFile(cwd, tsconfigPath)
81
+ // Report the rejected candidates before the error check and before the
82
+ // defaults path below: a search that ended empty examined them just the
83
+ // same, and falling back to the built-in defaults is exactly the state a
84
+ // config appearing later would change.
85
+ driver.ReportRejectedConfigCandidates(probed, hashReporter, realpathReporter)
72
86
  if err != nil {
73
87
  return nil, err
74
88
  }
@@ -81,11 +95,12 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
81
95
  return map[string]any{}, nil
82
96
  }
83
97
 
84
- raw, err := loadStripConfigFile(configFilePath, resolutionRoot)
98
+ loaded, err := loadStripConfigFileWithInputs(configFilePath, resolutionRoot)
85
99
  if err != nil {
86
100
  return nil, err
87
101
  }
88
- cfg, ok := raw.(map[string]any)
102
+ reportStripConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
103
+ cfg, ok := loaded.value.(map[string]any)
89
104
  if !ok {
90
105
  return nil, fmt.Errorf("@ttsc/strip: config file %s must export an object", configFilePath)
91
106
  }
@@ -96,36 +111,32 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
96
111
  // tsconfig is set) and returns the first directory that contains exactly one
97
112
  // strip.config.* file. Multiple candidates in the same directory is an error.
98
113
  // 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
114
+ //
115
+ // The second return value is every candidate the walk examined and rejected,
116
+ // each carrying whether it was absent or a directory wearing the name. Those
117
+ // decide the result as much as the file it returned: one created
118
+ // nearer the entry wins the next search, and one created beside the match makes
119
+ // that directory ambiguous. Without them a persistent consumer keeps applying
120
+ // the rules of a config a cold run would no longer choose — or keeps stripping
121
+ // under the built-in defaults after a real config appeared
122
+ // (samchon/ttsc#1271).
123
+ func findStripConfigFile(cwd, tsconfigPath string) (string, []driver.ConfigCandidate, error) {
124
+ discovery := driver.DiscoverConfigFile(stripDiscoveryBaseDir(cwd, tsconfigPath), stripConfigFilenames)
125
+ if len(discovery.Matches) > 1 {
126
+ names := make([]string, 0, len(discovery.Matches))
127
+ for _, match := range discovery.Matches {
128
+ names = append(names, filepath.Base(match))
126
129
  }
127
- dir = parent
130
+ return "", discovery.Probed, fmt.Errorf(
131
+ "@ttsc/strip: multiple strip config files found in %s (%s); "+
132
+ "set \"configFile\" explicitly in the tsconfig plugin entry",
133
+ discovery.Directory, strings.Join(names, ", "),
134
+ )
135
+ }
136
+ if len(discovery.Matches) == 1 {
137
+ return discovery.Matches[0], discovery.Probed, nil
128
138
  }
139
+ return "", discovery.Probed, nil
129
140
  }
130
141
 
131
142
  // stripDiscoveryBaseDir returns the directory from which auto-discovery walks
@@ -157,16 +168,117 @@ func resolveStripConfigFilePath(configPath, cwd, tsconfigPath string) string {
157
168
  // see stripConfigToolAnchors. The JSON and JS branches spawn no ttsx and
158
169
  // ignore it.
159
170
  func loadStripConfigFile(location, resolutionRoot string) (any, error) {
171
+ loaded, err := loadStripConfigFileWithInputs(location, resolutionRoot)
172
+ return loaded.value, err
173
+ }
174
+
175
+ type stripLoadedConfig struct {
176
+ hashes map[string]*string
177
+ inputs []string
178
+ realpaths map[string]*string
179
+ value any
180
+ }
181
+
182
+ func loadStripConfigFileWithInputs(location, resolutionRoot string) (stripLoadedConfig, error) {
160
183
  ext := strings.ToLower(filepath.Ext(location))
161
184
  switch ext {
162
185
  case ".json":
163
- return loadStripJSONConfigFile(location)
186
+ body, err := os.ReadFile(location)
187
+ if err != nil {
188
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
189
+ }
190
+ value, err := parseStripJSONConfigFile(location, body)
191
+ digest := fmt.Sprintf("%x", sha256.Sum256(body))
192
+ return stripLoadedConfig{hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: stripPhysicalHostInput(location)}, value: value}, err
164
193
  case ".js", ".cjs", ".mjs":
165
- return loadStripScriptConfigFile(location)
194
+ return loadStripScriptConfigFileWithInputs(location)
166
195
  case ".ts", ".cts", ".mts":
167
- return loadStripTypeScriptConfigFile(location, resolutionRoot)
196
+ return loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot)
168
197
  default:
169
- return nil, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
198
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
199
+ }
200
+ }
201
+
202
+ func reportStripConfigInputs(inputs []string, hashes, realpaths map[string]*string, reporter func(string), hashReporter, realpathReporter func(string, *string)) {
203
+ if reporter == nil && hashReporter == nil && realpathReporter == nil {
204
+ return
205
+ }
206
+ for _, input := range inputs {
207
+ if reporter != nil {
208
+ reporter(input)
209
+ }
210
+ if hashReporter != nil {
211
+ if hash, ok := hashes[input]; ok {
212
+ hashReporter(input, hash)
213
+ }
214
+ }
215
+ if realpathReporter != nil {
216
+ if realpath, ok := realpaths[input]; ok {
217
+ realpathReporter(input, realpath)
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ func stripPhysicalHostInput(file string) *string {
224
+ resolved, err := filepath.Abs(file)
225
+ if err != nil {
226
+ return nil
227
+ }
228
+ resolved = filepath.Clean(resolved)
229
+ seen := make(map[string]struct{})
230
+ for range 255 {
231
+ if _, exists := seen[resolved]; exists {
232
+ return nil
233
+ }
234
+ seen[resolved] = struct{}{}
235
+ if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil {
236
+ evaluated, evalErr = filepath.Abs(evaluated)
237
+ if evalErr != nil {
238
+ return nil
239
+ }
240
+ evaluated = filepath.Clean(evaluated)
241
+ if _, statErr := os.Stat(evaluated); statErr != nil {
242
+ return nil
243
+ }
244
+ return &evaluated
245
+ }
246
+ next, ok := stripResolveHostInputLinkAncestor(resolved)
247
+ if !ok {
248
+ return nil
249
+ }
250
+ resolved = next
251
+ }
252
+ return nil
253
+ }
254
+
255
+ // stripResolveHostInputLinkAncestor follows the nearest link-like ancestor and
256
+ // reattaches its remaining suffix. Windows junction children can be opened and
257
+ // os.Readlink exposes the junction itself even when EvalSymlinks rejects the
258
+ // complete child path.
259
+ func stripResolveHostInputLinkAncestor(location string) (string, bool) {
260
+ probe := filepath.Clean(location)
261
+ suffix := make([]string, 0)
262
+ for {
263
+ if target, err := os.Readlink(probe); err == nil {
264
+ if !filepath.IsAbs(target) {
265
+ target = filepath.Join(filepath.Dir(probe), target)
266
+ }
267
+ for i := len(suffix) - 1; i >= 0; i-- {
268
+ target = filepath.Join(target, suffix[i])
269
+ }
270
+ absolute, absErr := filepath.Abs(target)
271
+ if absErr != nil {
272
+ return "", false
273
+ }
274
+ return filepath.Clean(absolute), true
275
+ }
276
+ parent := filepath.Dir(probe)
277
+ if parent == probe {
278
+ return "", false
279
+ }
280
+ suffix = append(suffix, filepath.Base(probe))
281
+ probe = parent
170
282
  }
171
283
  }
172
284
 
@@ -178,6 +290,10 @@ func loadStripJSONConfigFile(location string) (any, error) {
178
290
  if err != nil {
179
291
  return nil, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
180
292
  }
293
+ return parseStripJSONConfigFile(location, body)
294
+ }
295
+
296
+ func parseStripJSONConfigFile(location string, body []byte) (any, error) {
181
297
  body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
182
298
  var out any
183
299
  if err := json.Unmarshal(body, &out); err != nil {
@@ -190,7 +306,229 @@ func loadStripJSONConfigFile(location string) (any, error) {
190
306
  // loadStripScriptConfigFile to evaluate a .js/.cjs/.mjs strip config and
191
307
  // serialize the result to stdout as JSON.
192
308
  const stripScriptLoaderSource = `
193
- const { pathToFileURL } = require("node:url");
309
+ const nodeModule = require("node:module");
310
+ const { createRequire, isBuiltin, registerHooks } = nodeModule;
311
+ const crypto = require("node:crypto");
312
+ const fs = require("node:fs");
313
+ const path = require("node:path");
314
+ const { fileURLToPath, pathToFileURL } = require("node:url");
315
+ const inputs = new Set();
316
+ const hashes = new Map();
317
+ const realpaths = new Map();
318
+ const signatures = new Map();
319
+ const unstableHashes = new Set();
320
+
321
+ function existingFile(file) {
322
+ try { return fs.statSync(file).isFile(); }
323
+ catch { return false; }
324
+ }
325
+
326
+ function missingPathError(error) {
327
+ return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
328
+ }
329
+
330
+ function inputMetadataSignature(file) {
331
+ const requested = path.resolve(file);
332
+ let current = requested;
333
+ for (;;) {
334
+ try {
335
+ const link = fs.lstatSync(current, { bigint: true });
336
+ let target = link;
337
+ if (link.isSymbolicLink()) {
338
+ try { target = fs.statSync(current, { bigint: true }); }
339
+ catch { return undefined; }
340
+ }
341
+ return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
342
+ } catch (error) {
343
+ if (!missingPathError(error)) return undefined;
344
+ const parent = path.dirname(current);
345
+ if (parent === current) return undefined;
346
+ current = parent;
347
+ }
348
+ }
349
+ }
350
+
351
+ function recordInput(file) {
352
+ file = path.resolve(file);
353
+ inputs.add(file);
354
+ if (unstableHashes.has(file)) return;
355
+ const beforeSignature = inputMetadataSignature(file);
356
+ let observed;
357
+ let observedRealpath;
358
+ try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
359
+ catch { observed = null; }
360
+ try { observedRealpath = fs.realpathSync.native(file); }
361
+ catch { observedRealpath = null; }
362
+ const afterSignature = inputMetadataSignature(file);
363
+ if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (signatures.has(file) && signatures.get(file) !== afterSignature) || (hashes.has(file) && hashes.get(file) !== observed) || (realpaths.has(file) && realpaths.get(file) !== observedRealpath)) {
364
+ hashes.delete(file);
365
+ realpaths.delete(file);
366
+ signatures.delete(file);
367
+ unstableHashes.add(file);
368
+ return;
369
+ }
370
+ signatures.set(file, afterSignature);
371
+ hashes.set(file, observed);
372
+ realpaths.set(file, observedRealpath);
373
+ }
374
+
375
+ function recordFile(file) {
376
+ const resolvedFile = path.resolve(file);
377
+ recordInput(resolvedFile);
378
+ for (let directory = path.dirname(resolvedFile);;) {
379
+ const manifest = path.join(directory, "package.json");
380
+ recordInput(manifest);
381
+ if (existingFile(manifest)) {
382
+ break;
383
+ }
384
+ const parent = path.dirname(directory);
385
+ if (parent === directory) {
386
+ break;
387
+ }
388
+ directory = parent;
389
+ }
390
+ }
391
+
392
+ function recordPackageManifests(file) {
393
+ for (let directory = path.dirname(path.resolve(file));;) {
394
+ const manifest = path.join(directory, "package.json");
395
+ recordInput(manifest);
396
+ if (existingFile(manifest)) return;
397
+ const parent = path.dirname(directory);
398
+ if (parent === directory) return;
399
+ directory = parent;
400
+ }
401
+ }
402
+
403
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];
404
+ function moduleCandidates(base) {
405
+ return [
406
+ base,
407
+ ...moduleProbeExtensions.map((extension) => base + extension),
408
+ path.join(base, "package.json"),
409
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
410
+ ];
411
+ }
412
+ const recordedModuleBases = new Set();
413
+ function recordManifestTargets(value, directory, allowBare = false) {
414
+ if (typeof value === "string") {
415
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
416
+ return;
417
+ }
418
+ if (Array.isArray(value)) {
419
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
420
+ return;
421
+ }
422
+ if (value && typeof value === "object") {
423
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
424
+ }
425
+ }
426
+ function recordModuleCandidates(base) {
427
+ const resolvedBase = path.resolve(base);
428
+ if (recordedModuleBases.has(resolvedBase)) return;
429
+ recordedModuleBases.add(resolvedBase);
430
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
431
+ try {
432
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
433
+ recordManifestTargets(manifest.exports, resolvedBase);
434
+ recordManifestTargets(manifest.module, resolvedBase, true);
435
+ recordManifestTargets(manifest.main, resolvedBase, true);
436
+ } catch {}
437
+ }
438
+ function candidateSelected(base, resolvedFile) {
439
+ for (const candidate of moduleCandidates(base)) {
440
+ try {
441
+ const canonical = fs.realpathSync.native(candidate);
442
+ const relative = path.relative(canonical, resolvedFile);
443
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
444
+ } catch {}
445
+ }
446
+ return false;
447
+ }
448
+ function localBases(specifier, parentDirectory) {
449
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
450
+ const raw = path.resolve(parentDirectory, specifier);
451
+ const suffixStart = specifier.search(/[?#]/);
452
+ if (suffixStart === -1) return [raw];
453
+ const pathname = specifier.slice(0, suffixStart);
454
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
455
+ }
456
+ function recordResolutionCandidates(specifier, parentURL, resolvedURL) {
457
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
458
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
459
+ let resolvedFile;
460
+ try {
461
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
462
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
463
+ : undefined;
464
+ } catch {}
465
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
466
+ try {
467
+ for (const base of localBases(specifier, parentDirectory)) {
468
+ recordPackageManifests(base);
469
+ let exact = false;
470
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
471
+ if (exact) recordInput(base);
472
+ else recordModuleCandidates(base);
473
+ }
474
+ } catch {}
475
+ return;
476
+ }
477
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
478
+ const parts = specifier.split("/");
479
+ const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
480
+ if (packageParts.some((part) => part === undefined || part === "")) return;
481
+ const packageName = packageParts.join("/");
482
+ const subpath = parts.slice(packageParts.length);
483
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
484
+ for (const searchPath of searchPaths) {
485
+ const packageDirectory = path.join(searchPath, packageName);
486
+ recordModuleCandidates(packageDirectory);
487
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
488
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
489
+ }
490
+ }
491
+
492
+ recordFile(process.argv[1]);
493
+ registerHooks({
494
+ resolve(specifier, context, nextResolve) {
495
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
496
+ const resolved = nextResolve(specifier, context);
497
+ const url = typeof resolved === "string" ? resolved : resolved && resolved.url;
498
+ recordResolutionCandidates(specifier, context.parentURL, url);
499
+ if (typeof url === "string" && url.startsWith("file:")) {
500
+ recordFile(fileURLToPath(url));
501
+ }
502
+ return resolved;
503
+ },
504
+ });
505
+
506
+ // The hook above never sees a require() made from inside a CommonJS module the
507
+ // ESM loader evaluated, which on Node 22 is every require the config makes:
508
+ // module.registerHooks observes the import() of that module and nothing within
509
+ // it. A config's own dependencies would then be reported without the candidates
510
+ // that decide them, so a spelling appearing later could change what the config
511
+ // resolves to with nothing in the envelope to notice it (samchon/ttsc#1280).
512
+ // Wrapping the CommonJS resolver records the same two observations the hook
513
+ // does, on the graph the hook cannot reach.
514
+ const nextResolveFilename = nodeModule._resolveFilename;
515
+ nodeModule._resolveFilename = function resolveFilename(request, parent, isMain, options) {
516
+ // _resolveFilename is an internal entry point anything may call, so a
517
+ // non-string request arrives here as readily as a specifier does. Reading it
518
+ // would replace Node's own argument error with a TypeError from this loader.
519
+ if (typeof request !== "string") {
520
+ return nextResolveFilename.call(this, request, parent, isMain, options);
521
+ }
522
+ const parentFile = parent && typeof parent.filename === "string" ? parent.filename : undefined;
523
+ const parentURL = parentFile === undefined ? undefined : pathToFileURL(parentFile).href;
524
+ recordResolutionCandidates(request, parentURL, undefined);
525
+ const resolved = nextResolveFilename.call(this, request, parent, isMain, options);
526
+ if (path.isAbsolute(resolved)) {
527
+ recordResolutionCandidates(request, parentURL, pathToFileURL(resolved).href);
528
+ recordFile(resolved);
529
+ }
530
+ return resolved;
531
+ };
194
532
 
195
533
  (async () => {
196
534
  const mod = await import(pathToFileURL(process.argv[1]).href);
@@ -206,15 +544,11 @@ const { pathToFileURL } = require("node:url");
206
544
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
207
545
  throw new Error("strip config file must export an object");
208
546
  }
209
- process.stdout.write(JSON.stringify(value));
547
+ const serializedValue = JSON.stringify(value);
548
+ for (const input of [...inputs]) recordInput(input);
549
+ process.stdout.write(JSON.stringify({ value: JSON.parse(serializedValue), hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
210
550
  })().catch((error) => {
211
551
  process.stderr.write(error && error.stack ? error.stack : String(error));
212
- // The stack above is for the reader. This is for the caller: the parent reads
213
- // stdout as the payload channel either way, so a failure reason travels as
214
- // data rather than as text scraped back out of a captured stream. The exit
215
- // code is set before the write so a callback that never fires still fails the
216
- // load, and the write's completion is what triggers the exit, because
217
- // process.exit abandons a pending pipe write.
218
552
  process.exitCode = 1;
219
553
  process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
220
554
  });
@@ -224,13 +558,26 @@ const { pathToFileURL } = require("node:url");
224
558
  // Node subprocess that dynamic-imports the file, resolves the default export,
225
559
  // and serializes the result as JSON to stdout.
226
560
  func loadStripScriptConfigFile(location string) (any, error) {
561
+ loaded, err := loadStripScriptConfigFileWithInputs(location)
562
+ return loaded.value, err
563
+ }
564
+
565
+ func loadStripScriptConfigFileWithInputs(location string) (stripLoadedConfig, error) {
227
566
  node := os.Getenv("TTSC_NODE_BINARY")
228
567
  if node == "" {
229
568
  node = "node"
230
569
  }
231
570
  ctx, cancel := context.WithCancel(context.Background())
232
571
  defer cancel()
233
- cmd := exec.CommandContext(ctx, node, "-e", stripScriptLoaderSource, location)
572
+ // Windows limits the whole process command line to roughly 32 KiB. The
573
+ // dependency-tracking loader is intentionally larger than that, so keep only
574
+ // an explicit CommonJS stdin program and remove Node's stdin sentinel before
575
+ // the loader runs. This preserves the historical process.argv layout seen by
576
+ // both the loader and the imported user config without using string eval.
577
+ cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
578
+ cmd.Stdin = strings.NewReader(
579
+ "process.argv.splice(1, 1);\n" + stripScriptLoaderSource,
580
+ )
234
581
  cmd.Env = stripNodeConfigLoaderEnv(location)
235
582
  // The child's stderr is human output and goes straight to this process's
236
583
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -243,15 +590,46 @@ func loadStripScriptConfigFile(location string) (any, error) {
243
590
  // What it could not put there is a reason a caller can act on, so that
244
591
  // arrives through the payload channel instead.
245
592
  if reason := loaderFailureReason(output); reason != "" {
246
- return nil, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, reason)
593
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, reason)
247
594
  }
248
- return nil, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
595
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
249
596
  }
250
- var out any
251
- if err := json.Unmarshal(output, &out); err != nil {
252
- return nil, fmt.Errorf("@ttsc/strip: parse config file %s output: %w", location, err)
597
+ loaded, err := decodeStripConfigLoaderOutput(output)
598
+ if err != nil {
599
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: parse config file %s output: %w", location, err)
253
600
  }
254
- return out, nil
601
+ return loaded, nil
602
+ }
603
+
604
+ func decodeStripConfigLoaderOutput(output []byte) (stripLoadedConfig, error) {
605
+ var envelope struct {
606
+ Error string `json:"__ttscLoaderError"`
607
+ Hashes map[string]*string `json:"hashes"`
608
+ Inputs []string `json:"inputs"`
609
+ Realpaths map[string]*string `json:"realpaths"`
610
+ Value json.RawMessage `json:"value"`
611
+ }
612
+ if err := json.Unmarshal(output, &envelope); err != nil {
613
+ return stripLoadedConfig{}, err
614
+ }
615
+ if envelope.Error != "" {
616
+ return stripLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
617
+ }
618
+ if len(envelope.Value) == 0 {
619
+ // Test/fallback launchers written against the historical payload return
620
+ // the config value directly. Preserve that accepted contract while real
621
+ // loaders use the envelope to carry runtime inputs.
622
+ var value any
623
+ if err := json.Unmarshal(output, &value); err != nil {
624
+ return stripLoadedConfig{}, err
625
+ }
626
+ return stripLoadedConfig{value: value}, nil
627
+ }
628
+ var value any
629
+ if err := json.Unmarshal(envelope.Value, &value); err != nil {
630
+ return stripLoadedConfig{}, err
631
+ }
632
+ return stripLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
255
633
  }
256
634
 
257
635
  // stripTypeScriptLoaderSource returns the TypeScript source of the ephemeral
@@ -259,7 +637,260 @@ func loadStripScriptConfigFile(location string) (any, error) {
259
637
  // importLiteral must be a JSON-encoded relative import path (e.g.
260
638
  // `"./strip.config.ts"`) produced by json.Marshal.
261
639
  func stripTypeScriptLoaderSource(importLiteral string) string {
262
- return fmt.Sprintf(`import * as importedConfig from %s;
640
+ return fmt.Sprintf(`// @ts-nocheck
641
+ import Module, { createRequire, isBuiltin, registerHooks } from "node:module";
642
+ import crypto from "node:crypto";
643
+ import fs from "node:fs";
644
+ import path from "node:path";
645
+ import { fileURLToPath, pathToFileURL } from "node:url";
646
+
647
+ const inputs = new Set<string>();
648
+ const hashes = new Map<string, string | null>();
649
+ const realpaths = new Map<string, string | null>();
650
+ const signatures = new Map<string, string>();
651
+ const unstableHashes = new Set<string>();
652
+
653
+ function existingFile(file: string): boolean {
654
+ try { return fs.statSync(file).isFile(); }
655
+ catch { return false; }
656
+ }
657
+
658
+ function missingPathError(error: unknown): boolean {
659
+ const code = (error as { code?: unknown } | undefined)?.code;
660
+ return code === "ENOENT" || code === "ENOTDIR";
661
+ }
662
+
663
+ function inputMetadataSignature(file: string): string | undefined {
664
+ const requested = path.resolve(file);
665
+ let current = requested;
666
+ for (;;) {
667
+ try {
668
+ const link = fs.lstatSync(current, { bigint: true });
669
+ let target = link;
670
+ if (link.isSymbolicLink()) {
671
+ try { target = fs.statSync(current, { bigint: true }); }
672
+ catch { return undefined; }
673
+ }
674
+ return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
675
+ } catch (error) {
676
+ if (!missingPathError(error)) return undefined;
677
+ const parent = path.dirname(current);
678
+ if (parent === current) return undefined;
679
+ current = parent;
680
+ }
681
+ }
682
+ }
683
+
684
+ function recordInput(file: string): void {
685
+ file = path.resolve(file);
686
+ inputs.add(file);
687
+ if (unstableHashes.has(file)) return;
688
+ const beforeSignature = inputMetadataSignature(file);
689
+ let observed: string | null;
690
+ let observedRealpath: string | null;
691
+ try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
692
+ catch { observed = null; }
693
+ try { observedRealpath = fs.realpathSync.native(file); }
694
+ catch { observedRealpath = null; }
695
+ const afterSignature = inputMetadataSignature(file);
696
+ if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (signatures.has(file) && signatures.get(file) !== afterSignature) || (hashes.has(file) && hashes.get(file) !== observed) || (realpaths.has(file) && realpaths.get(file) !== observedRealpath)) {
697
+ hashes.delete(file);
698
+ realpaths.delete(file);
699
+ signatures.delete(file);
700
+ unstableHashes.add(file);
701
+ return;
702
+ }
703
+ signatures.set(file, afterSignature);
704
+ hashes.set(file, observed);
705
+ realpaths.set(file, observedRealpath);
706
+ }
707
+
708
+ function recordFile(file: string): void {
709
+ const resolvedFile = path.resolve(file);
710
+ recordInput(resolvedFile);
711
+ for (let directory = path.dirname(resolvedFile);;) {
712
+ const manifest = path.join(directory, "package.json");
713
+ recordInput(manifest);
714
+ if (existingFile(manifest)) {
715
+ break;
716
+ }
717
+ const parent = path.dirname(directory);
718
+ if (parent === directory) {
719
+ break;
720
+ }
721
+ directory = parent;
722
+ }
723
+ }
724
+
725
+ function recordPackageManifests(file: string): void {
726
+ for (let directory = path.dirname(path.resolve(file));;) {
727
+ const manifest = path.join(directory, "package.json");
728
+ recordInput(manifest);
729
+ if (existingFile(manifest)) return;
730
+ const parent = path.dirname(directory);
731
+ if (parent === directory) return;
732
+ directory = parent;
733
+ }
734
+ }
735
+
736
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] as const;
737
+ const jsToTsProbeExtensions = new Map<string, readonly string[]>([
738
+ [".js", [".ts", ".tsx"]],
739
+ [".jsx", [".tsx"]],
740
+ [".mjs", [".mts"]],
741
+ [".cjs", [".cts"]],
742
+ ]);
743
+ function sourceSubstitutionCandidates(base: string): string[] {
744
+ const extension = path.extname(base).toLowerCase();
745
+ const substitutions = jsToTsProbeExtensions.get(extension);
746
+ if (substitutions === undefined) return [];
747
+ const stem = base.slice(0, base.length - extension.length);
748
+ return substitutions.map((candidate) => stem + candidate);
749
+ }
750
+ function moduleCandidates(base: string): string[] {
751
+ return [
752
+ base,
753
+ ...sourceSubstitutionCandidates(base),
754
+ ...moduleProbeExtensions.map((extension) => base + extension),
755
+ path.join(base, "package.json"),
756
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
757
+ ];
758
+ }
759
+ const recordedModuleBases = new Set<string>();
760
+ function recordManifestTargets(value: unknown, directory: string, allowBare: boolean = false): void {
761
+ if (typeof value === "string") {
762
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
763
+ return;
764
+ }
765
+ if (Array.isArray(value)) {
766
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
767
+ return;
768
+ }
769
+ if (value !== null && typeof value === "object") {
770
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
771
+ }
772
+ }
773
+ function recordModuleCandidates(base: string): void {
774
+ const resolvedBase = path.resolve(base);
775
+ if (recordedModuleBases.has(resolvedBase)) return;
776
+ recordedModuleBases.add(resolvedBase);
777
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
778
+ try {
779
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
780
+ recordManifestTargets(manifest.exports, resolvedBase);
781
+ recordManifestTargets(manifest.module, resolvedBase, true);
782
+ recordManifestTargets(manifest.main, resolvedBase, true);
783
+ } catch {}
784
+ }
785
+ function candidateSelected(base: string, resolvedFile: string): boolean {
786
+ for (const candidate of moduleCandidates(base)) {
787
+ try {
788
+ const canonical = fs.realpathSync.native(candidate);
789
+ const relative = path.relative(canonical, resolvedFile);
790
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
791
+ } catch {}
792
+ }
793
+ return false;
794
+ }
795
+ function localBases(specifier: string, parentDirectory: string): string[] {
796
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
797
+ const raw = path.resolve(parentDirectory, specifier);
798
+ const suffixStart = specifier.search(/[?#]/);
799
+ if (suffixStart === -1) return [raw];
800
+ const pathname = specifier.slice(0, suffixStart);
801
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
802
+ }
803
+ function recordResolutionCandidates(specifier: string, parentURL: string | undefined, resolvedURL: string | undefined): void {
804
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
805
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
806
+ let resolvedFile: string | undefined;
807
+ try {
808
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
809
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
810
+ : undefined;
811
+ } catch {}
812
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
813
+ try {
814
+ for (const base of localBases(specifier, parentDirectory)) {
815
+ recordPackageManifests(base);
816
+ let exact = false;
817
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
818
+ if (exact) recordInput(base);
819
+ else recordModuleCandidates(base);
820
+ }
821
+ } catch {}
822
+ return;
823
+ }
824
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
825
+ const parts = specifier.split("/");
826
+ const packageParts = parts[0]!.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
827
+ if (packageParts.some((part) => part === undefined || part === "")) return;
828
+ const packageName = packageParts.join("/");
829
+ const subpath = parts.slice(packageParts.length);
830
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
831
+ for (const searchPath of searchPaths) {
832
+ const packageDirectory = path.join(searchPath, packageName);
833
+ recordModuleCandidates(packageDirectory);
834
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
835
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
836
+ }
837
+ }
838
+
839
+ registerHooks({
840
+ resolve(specifier, context, nextResolve) {
841
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
842
+ const resolved = nextResolve(specifier, context);
843
+ const url = typeof resolved === "string" ? resolved : resolved?.url;
844
+ recordResolutionCandidates(specifier, context.parentURL, url);
845
+ if (typeof url === "string" && url.startsWith("file:")) {
846
+ recordFile(fileURLToPath(url));
847
+ }
848
+ return resolved;
849
+ },
850
+ });
851
+
852
+ // The hook above never sees a require() made from inside a CommonJS module the
853
+ // ESM loader evaluated, which on Node 22 is every require the config makes:
854
+ // module.registerHooks observes the import() of that module and nothing within
855
+ // it. A config's own dependencies would then be reported without the candidates
856
+ // that decide them, so a spelling appearing later could change what the config
857
+ // resolves to with nothing in the envelope to notice it (samchon/ttsc#1280).
858
+ // Wrapping the CommonJS resolver records the same two observations the hook
859
+ // does, on the graph the hook cannot reach.
860
+ const moduleInternals = Module as unknown as {
861
+ _resolveFilename(
862
+ request: string,
863
+ parent: { filename?: string | null } | null | undefined,
864
+ isMain: boolean,
865
+ options?: unknown,
866
+ ): string;
867
+ };
868
+ const nextResolveFilename = moduleInternals._resolveFilename;
869
+ moduleInternals._resolveFilename = function resolveFilename(
870
+ this: unknown,
871
+ request: string,
872
+ parent: { filename?: string | null } | null | undefined,
873
+ isMain: boolean,
874
+ options?: unknown,
875
+ ): string {
876
+ // _resolveFilename is an internal entry point anything may call, so a
877
+ // non-string request arrives here as readily as a specifier does. Reading it
878
+ // would replace Node's own argument error with a TypeError from this loader.
879
+ if (typeof request !== "string") {
880
+ return nextResolveFilename.call(this, request, parent, isMain, options);
881
+ }
882
+ const parentURL =
883
+ typeof parent?.filename === "string"
884
+ ? pathToFileURL(parent.filename).href
885
+ : undefined;
886
+ recordResolutionCandidates(request, parentURL, undefined);
887
+ const resolved = nextResolveFilename.call(this, request, parent, isMain, options);
888
+ if (path.isAbsolute(resolved)) {
889
+ recordResolutionCandidates(request, parentURL, pathToFileURL(resolved).href);
890
+ recordFile(resolved);
891
+ }
892
+ return resolved;
893
+ };
263
894
 
264
895
  declare const process: {
265
896
  exitCode?: number;
@@ -275,6 +906,7 @@ declare const process: {
275
906
  // left for a trailing handler to settle.
276
907
  (async () => {
277
908
  try {
909
+ const importedConfig = await import(%s);
278
910
  let current: unknown = importedConfig;
279
911
  for (let i = 0; i < 8; i++) {
280
912
  if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current as Record<string, unknown>, "default")) {
@@ -289,7 +921,9 @@ declare const process: {
289
921
  if (current === null || typeof current !== "object" || Array.isArray(current)) {
290
922
  throw new Error("strip config file must export an object");
291
923
  }
292
- process.stdout.write(JSON.stringify(current));
924
+ const serializedValue = JSON.stringify(current);
925
+ for (const input of [...inputs]) recordInput(input);
926
+ process.stdout.write(JSON.stringify({ value: JSON.parse(serializedValue), hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
293
927
  } catch (error) {
294
928
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
295
929
  // The stack above is for the reader. This is for the caller: the parent
@@ -321,31 +955,36 @@ declare const process: {
321
955
  // resolved from the project rather than from the process environment alone;
322
956
  // see stripConfigToolAnchors.
323
957
  func loadStripTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
958
+ loaded, err := loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot)
959
+ return loaded.value, err
960
+ }
961
+
962
+ func loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot string) (stripLoadedConfig, error) {
324
963
  tempDir, err := os.MkdirTemp(stripLoaderTempBase(location, os.TempDir()), "ttsc-strip-config-")
325
964
  if err != nil {
326
- return nil, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
965
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
327
966
  }
328
967
  defer os.RemoveAll(tempDir)
329
968
 
330
969
  if err := stripLinkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
331
- return nil, err
970
+ return stripLoadedConfig{}, err
332
971
  }
333
972
 
334
973
  loader := filepath.Join(tempDir, "loader.mts")
335
974
  tsconfig := filepath.Join(tempDir, "tsconfig.json")
336
975
  importSpecifier, err := stripRelativeImportSpecifier(tempDir, location)
337
976
  if err != nil {
338
- return nil, err
977
+ return stripLoadedConfig{}, err
339
978
  }
340
979
  importLiteral, err := json.Marshal(importSpecifier)
341
980
  if err != nil {
342
- return nil, fmt.Errorf("@ttsc/strip: encode config import %s: %w", location, err)
981
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: encode config import %s: %w", location, err)
343
982
  }
344
983
  if err := os.WriteFile(loader, []byte(stripTypeScriptLoaderSource(string(importLiteral))), 0o644); err != nil {
345
- return nil, fmt.Errorf("@ttsc/strip: write config loader: %w", err)
984
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: write config loader: %w", err)
346
985
  }
347
986
  if err := os.WriteFile(tsconfig, []byte(stripTypeScriptLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
348
- return nil, fmt.Errorf("@ttsc/strip: write config loader tsconfig: %w", err)
987
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: write config loader tsconfig: %w", err)
349
988
  }
350
989
 
351
990
  args := []string{
@@ -375,15 +1014,15 @@ func loadStripTypeScriptConfigFile(location, resolutionRoot string) (any, error)
375
1014
  // What it could not put there is a reason a caller can act on, so that
376
1015
  // arrives through the payload channel instead.
377
1016
  if reason := loaderFailureReason(output); reason != "" {
378
- return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, reason)
1017
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, reason)
379
1018
  }
380
- return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
1019
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
381
1020
  }
382
- var out any
383
- if err := json.Unmarshal(output, &out); err != nil {
384
- return nil, fmt.Errorf("@ttsc/strip: parse TypeScript config file %s output: %w", location, err)
1021
+ loaded, err := decodeStripConfigLoaderOutput(output)
1022
+ if err != nil {
1023
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: parse TypeScript config file %s output: %w", location, err)
385
1024
  }
386
- return out, nil
1025
+ return loaded, nil
387
1026
  }
388
1027
 
389
1028
  // stripTypeScriptLoaderTsconfig generates the JSON content of the ephemeral
@@ -402,6 +1041,7 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
402
1041
  // resolving either way.
403
1042
  "module": stripConfigModuleOption(location),
404
1043
  "moduleResolution": "bundler",
1044
+ "jsx": "preserve",
405
1045
  "noImplicitAny": false,
406
1046
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
407
1047
  "rewriteRelativeImportExtensions": true,
package/driver/strip.go CHANGED
@@ -19,7 +19,7 @@ type plugin struct{}
19
19
  // ApplyProgram strips configured call expressions and debugger statements from
20
20
  // every source file in the program.
21
21
  func (plugin) ApplyProgram(prog *driver.Program, ctx driver.PluginContext) error {
22
- config, err := loadStripConfigMap(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
22
+ config, err := loadStripConfigMapWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
23
23
  if err != nil {
24
24
  return err
25
25
  }
@@ -27,6 +27,11 @@ func (plugin) ApplyProgram(prog *driver.Program, ctx driver.PluginContext) error
27
27
  if err != nil {
28
28
  return err
29
29
  }
30
+ // The rewrite reads the statements in front of it and the configured
31
+ // patterns, never the checker and never another file, so what a file's
32
+ // output depends on is that file's own text plus strip.config.*, which was
33
+ // reported above as a host input (samchon/ttsc#1263).
34
+ ctx.ReportDependenciesComplete()
30
35
  for _, file := range prog.SourceFiles() {
31
36
  rewriter.apply(file)
32
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/strip",
3
- "version": "0.26.2",
3
+ "version": "0.28.0",
4
4
  "description": "First-party ttsc plugin that removes configured calls and statements from emitted JavaScript.",
5
5
  "main": "src/index.cjs",
6
6
  "types": "src/index.d.ts",
package/src/index.cjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  "use strict";
3
3
 
4
+ const fs = require("node:fs");
5
+ const crypto = require("node:crypto");
4
6
  const path = require("node:path");
5
7
 
6
8
  // Keys from the tsconfig plugin entry that @ttsc/strip accepts. All other keys
@@ -28,7 +30,11 @@ module.exports = function createTtscStrip(context) {
28
30
  );
29
31
  }
30
32
  }
33
+ const configInputs = stripConfigInputs(context);
31
34
  return {
35
+ hostInputHashes: configInputs.hashes,
36
+ hostInputRealpaths: configInputs.realpaths,
37
+ hostInputs: configInputs.inputs,
32
38
  name: "@ttsc/strip",
33
39
  // `context.dirname` is this descriptor's own directory in every load mode —
34
40
  // the ESM-safe replacement for `__dirname`.
@@ -36,3 +42,82 @@ module.exports = function createTtscStrip(context) {
36
42
  stage: "transform",
37
43
  };
38
44
  };
45
+
46
+ const STRIP_CONFIG_FILENAMES = [
47
+ "strip.config.ts",
48
+ "strip.config.mts",
49
+ "strip.config.cts",
50
+ "strip.config.js",
51
+ "strip.config.mjs",
52
+ "strip.config.cjs",
53
+ "strip.config.json",
54
+ ];
55
+
56
+ function stripConfigInputs(context) {
57
+ const configFile = context.plugin?.configFile;
58
+ const base = path.resolve(
59
+ context.pluginConfigDir ?? path.dirname(context.tsconfig),
60
+ );
61
+ if (typeof configFile === "string" && configFile.trim() !== "") {
62
+ const file = path.isAbsolute(configFile)
63
+ ? path.resolve(configFile)
64
+ : path.resolve(base, configFile);
65
+ return {
66
+ hashes: { [file]: hostInputHash(file) },
67
+ inputs: [file],
68
+ realpaths: { [file]: hostInputRealpath(file) },
69
+ };
70
+ }
71
+ const inputs = [];
72
+ const hashes = {};
73
+ const realpaths = {};
74
+ for (let directory = base; ; directory = path.dirname(directory)) {
75
+ const candidates = STRIP_CONFIG_FILENAMES.map((name) =>
76
+ path.join(directory, name),
77
+ );
78
+ inputs.push(...candidates);
79
+ for (const candidate of candidates) {
80
+ hashes[candidate] = hostInputHash(candidate);
81
+ realpaths[candidate] = hostInputRealpath(candidate);
82
+ }
83
+ if (candidates.some(configCandidateExists)) break;
84
+ const parent = path.dirname(directory);
85
+ if (parent === directory) break;
86
+ }
87
+ return { hashes, inputs, realpaths };
88
+ }
89
+
90
+ function hostInputRealpath(file) {
91
+ try {
92
+ return fs.realpathSync.native(file);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** Hash the exact candidate state observed before discovery selects a file. */
99
+ function hostInputHash(file) {
100
+ try {
101
+ if (fs.statSync(file).isDirectory()) {
102
+ return crypto
103
+ .createHash("sha256")
104
+ .update("ttsc:host-input:directory\0")
105
+ .digest("hex");
106
+ }
107
+ return crypto
108
+ .createHash("sha256")
109
+ .update(fs.readFileSync(file))
110
+ .digest("hex");
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /** Match the native discovery rule: a directory is never a config file. */
117
+ function configCandidateExists(file) {
118
+ try {
119
+ return !fs.statSync(file).isDirectory();
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
package/src/index.d.ts CHANGED
@@ -14,9 +14,16 @@ declare function createTtscStrip(context: {
14
14
  * it.
15
15
  */
16
16
  dirname: string;
17
+ /** Host-declared anchor for implicit strip config discovery. */
18
+ pluginConfigDir?: string;
17
19
  /** Original tsconfig plugin entry, validated for unsupported keys. */
18
20
  plugin?: Record<string, unknown>;
21
+ /** Absolute resolved tsconfig path. */
22
+ tsconfig: string;
19
23
  }): {
24
+ hostInputHashes: Record<string, string | null>;
25
+ hostInputRealpaths: Record<string, string | null>;
26
+ hostInputs: string[];
20
27
  name: string;
21
28
  source: string;
22
29
  stage: "transform";