@ttsc/strip 0.26.2 → 0.27.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.
@@ -81,11 +90,12 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
81
90
  return map[string]any{}, nil
82
91
  }
83
92
 
84
- raw, err := loadStripConfigFile(configFilePath, resolutionRoot)
93
+ loaded, err := loadStripConfigFileWithInputs(configFilePath, resolutionRoot)
85
94
  if err != nil {
86
95
  return nil, err
87
96
  }
88
- cfg, ok := raw.(map[string]any)
97
+ reportStripConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
98
+ cfg, ok := loaded.value.(map[string]any)
89
99
  if !ok {
90
100
  return nil, fmt.Errorf("@ttsc/strip: config file %s must export an object", configFilePath)
91
101
  }
@@ -157,16 +167,117 @@ func resolveStripConfigFilePath(configPath, cwd, tsconfigPath string) string {
157
167
  // see stripConfigToolAnchors. The JSON and JS branches spawn no ttsx and
158
168
  // ignore it.
159
169
  func loadStripConfigFile(location, resolutionRoot string) (any, error) {
170
+ loaded, err := loadStripConfigFileWithInputs(location, resolutionRoot)
171
+ return loaded.value, err
172
+ }
173
+
174
+ type stripLoadedConfig struct {
175
+ hashes map[string]*string
176
+ inputs []string
177
+ realpaths map[string]*string
178
+ value any
179
+ }
180
+
181
+ func loadStripConfigFileWithInputs(location, resolutionRoot string) (stripLoadedConfig, error) {
160
182
  ext := strings.ToLower(filepath.Ext(location))
161
183
  switch ext {
162
184
  case ".json":
163
- return loadStripJSONConfigFile(location)
185
+ body, err := os.ReadFile(location)
186
+ if err != nil {
187
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
188
+ }
189
+ value, err := parseStripJSONConfigFile(location, body)
190
+ digest := fmt.Sprintf("%x", sha256.Sum256(body))
191
+ return stripLoadedConfig{hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: stripPhysicalHostInput(location)}, value: value}, err
164
192
  case ".js", ".cjs", ".mjs":
165
- return loadStripScriptConfigFile(location)
193
+ return loadStripScriptConfigFileWithInputs(location)
166
194
  case ".ts", ".cts", ".mts":
167
- return loadStripTypeScriptConfigFile(location, resolutionRoot)
195
+ return loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot)
168
196
  default:
169
- return nil, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
197
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
198
+ }
199
+ }
200
+
201
+ func reportStripConfigInputs(inputs []string, hashes, realpaths map[string]*string, reporter func(string), hashReporter, realpathReporter func(string, *string)) {
202
+ if reporter == nil && hashReporter == nil && realpathReporter == nil {
203
+ return
204
+ }
205
+ for _, input := range inputs {
206
+ if reporter != nil {
207
+ reporter(input)
208
+ }
209
+ if hashReporter != nil {
210
+ if hash, ok := hashes[input]; ok {
211
+ hashReporter(input, hash)
212
+ }
213
+ }
214
+ if realpathReporter != nil {
215
+ if realpath, ok := realpaths[input]; ok {
216
+ realpathReporter(input, realpath)
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ func stripPhysicalHostInput(file string) *string {
223
+ resolved, err := filepath.Abs(file)
224
+ if err != nil {
225
+ return nil
226
+ }
227
+ resolved = filepath.Clean(resolved)
228
+ seen := make(map[string]struct{})
229
+ for range 255 {
230
+ if _, exists := seen[resolved]; exists {
231
+ return nil
232
+ }
233
+ seen[resolved] = struct{}{}
234
+ if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil {
235
+ evaluated, evalErr = filepath.Abs(evaluated)
236
+ if evalErr != nil {
237
+ return nil
238
+ }
239
+ evaluated = filepath.Clean(evaluated)
240
+ if _, statErr := os.Stat(evaluated); statErr != nil {
241
+ return nil
242
+ }
243
+ return &evaluated
244
+ }
245
+ next, ok := stripResolveHostInputLinkAncestor(resolved)
246
+ if !ok {
247
+ return nil
248
+ }
249
+ resolved = next
250
+ }
251
+ return nil
252
+ }
253
+
254
+ // stripResolveHostInputLinkAncestor follows the nearest link-like ancestor and
255
+ // reattaches its remaining suffix. Windows junction children can be opened and
256
+ // os.Readlink exposes the junction itself even when EvalSymlinks rejects the
257
+ // complete child path.
258
+ func stripResolveHostInputLinkAncestor(location string) (string, bool) {
259
+ probe := filepath.Clean(location)
260
+ suffix := make([]string, 0)
261
+ for {
262
+ if target, err := os.Readlink(probe); err == nil {
263
+ if !filepath.IsAbs(target) {
264
+ target = filepath.Join(filepath.Dir(probe), target)
265
+ }
266
+ for i := len(suffix) - 1; i >= 0; i-- {
267
+ target = filepath.Join(target, suffix[i])
268
+ }
269
+ absolute, absErr := filepath.Abs(target)
270
+ if absErr != nil {
271
+ return "", false
272
+ }
273
+ return filepath.Clean(absolute), true
274
+ }
275
+ parent := filepath.Dir(probe)
276
+ if parent == probe {
277
+ return "", false
278
+ }
279
+ suffix = append(suffix, filepath.Base(probe))
280
+ probe = parent
170
281
  }
171
282
  }
172
283
 
@@ -178,6 +289,10 @@ func loadStripJSONConfigFile(location string) (any, error) {
178
289
  if err != nil {
179
290
  return nil, fmt.Errorf("@ttsc/strip: read config file %s: %w", location, err)
180
291
  }
292
+ return parseStripJSONConfigFile(location, body)
293
+ }
294
+
295
+ func parseStripJSONConfigFile(location string, body []byte) (any, error) {
181
296
  body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
182
297
  var out any
183
298
  if err := json.Unmarshal(body, &out); err != nil {
@@ -190,7 +305,201 @@ func loadStripJSONConfigFile(location string) (any, error) {
190
305
  // loadStripScriptConfigFile to evaluate a .js/.cjs/.mjs strip config and
191
306
  // serialize the result to stdout as JSON.
192
307
  const stripScriptLoaderSource = `
193
- const { pathToFileURL } = require("node:url");
308
+ const { createRequire, isBuiltin, registerHooks } = require("node:module");
309
+ const crypto = require("node:crypto");
310
+ const fs = require("node:fs");
311
+ const path = require("node:path");
312
+ const { fileURLToPath, pathToFileURL } = require("node:url");
313
+ const inputs = new Set();
314
+ const hashes = new Map();
315
+ const realpaths = new Map();
316
+ const signatures = new Map();
317
+ const unstableHashes = new Set();
318
+
319
+ function existingFile(file) {
320
+ try { return fs.statSync(file).isFile(); }
321
+ catch { return false; }
322
+ }
323
+
324
+ function missingPathError(error) {
325
+ return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
326
+ }
327
+
328
+ function inputMetadataSignature(file) {
329
+ const requested = path.resolve(file);
330
+ let current = requested;
331
+ for (;;) {
332
+ try {
333
+ const link = fs.lstatSync(current, { bigint: true });
334
+ let target = link;
335
+ if (link.isSymbolicLink()) {
336
+ try { target = fs.statSync(current, { bigint: true }); }
337
+ catch { return undefined; }
338
+ }
339
+ 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(":");
340
+ } catch (error) {
341
+ if (!missingPathError(error)) return undefined;
342
+ const parent = path.dirname(current);
343
+ if (parent === current) return undefined;
344
+ current = parent;
345
+ }
346
+ }
347
+ }
348
+
349
+ function recordInput(file) {
350
+ file = path.resolve(file);
351
+ inputs.add(file);
352
+ if (unstableHashes.has(file)) return;
353
+ const beforeSignature = inputMetadataSignature(file);
354
+ let observed;
355
+ let observedRealpath;
356
+ 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"); }
357
+ catch { observed = null; }
358
+ try { observedRealpath = fs.realpathSync.native(file); }
359
+ catch { observedRealpath = null; }
360
+ const afterSignature = inputMetadataSignature(file);
361
+ 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)) {
362
+ hashes.delete(file);
363
+ realpaths.delete(file);
364
+ signatures.delete(file);
365
+ unstableHashes.add(file);
366
+ return;
367
+ }
368
+ signatures.set(file, afterSignature);
369
+ hashes.set(file, observed);
370
+ realpaths.set(file, observedRealpath);
371
+ }
372
+
373
+ function recordFile(file) {
374
+ const resolvedFile = path.resolve(file);
375
+ recordInput(resolvedFile);
376
+ for (let directory = path.dirname(resolvedFile);;) {
377
+ const manifest = path.join(directory, "package.json");
378
+ recordInput(manifest);
379
+ if (existingFile(manifest)) {
380
+ break;
381
+ }
382
+ const parent = path.dirname(directory);
383
+ if (parent === directory) {
384
+ break;
385
+ }
386
+ directory = parent;
387
+ }
388
+ }
389
+
390
+ function recordPackageManifests(file) {
391
+ for (let directory = path.dirname(path.resolve(file));;) {
392
+ const manifest = path.join(directory, "package.json");
393
+ recordInput(manifest);
394
+ if (existingFile(manifest)) return;
395
+ const parent = path.dirname(directory);
396
+ if (parent === directory) return;
397
+ directory = parent;
398
+ }
399
+ }
400
+
401
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];
402
+ function moduleCandidates(base) {
403
+ return [
404
+ base,
405
+ ...moduleProbeExtensions.map((extension) => base + extension),
406
+ path.join(base, "package.json"),
407
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
408
+ ];
409
+ }
410
+ const recordedModuleBases = new Set();
411
+ function recordManifestTargets(value, directory, allowBare = false) {
412
+ if (typeof value === "string") {
413
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
414
+ return;
415
+ }
416
+ if (Array.isArray(value)) {
417
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
418
+ return;
419
+ }
420
+ if (value && typeof value === "object") {
421
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
422
+ }
423
+ }
424
+ function recordModuleCandidates(base) {
425
+ const resolvedBase = path.resolve(base);
426
+ if (recordedModuleBases.has(resolvedBase)) return;
427
+ recordedModuleBases.add(resolvedBase);
428
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
429
+ try {
430
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
431
+ recordManifestTargets(manifest.exports, resolvedBase);
432
+ recordManifestTargets(manifest.module, resolvedBase, true);
433
+ recordManifestTargets(manifest.main, resolvedBase, true);
434
+ } catch {}
435
+ }
436
+ function candidateSelected(base, resolvedFile) {
437
+ for (const candidate of moduleCandidates(base)) {
438
+ try {
439
+ const canonical = fs.realpathSync.native(candidate);
440
+ const relative = path.relative(canonical, resolvedFile);
441
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
442
+ } catch {}
443
+ }
444
+ return false;
445
+ }
446
+ function localBases(specifier, parentDirectory) {
447
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
448
+ const raw = path.resolve(parentDirectory, specifier);
449
+ const suffixStart = specifier.search(/[?#]/);
450
+ if (suffixStart === -1) return [raw];
451
+ const pathname = specifier.slice(0, suffixStart);
452
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
453
+ }
454
+ function recordResolutionCandidates(specifier, parentURL, resolvedURL) {
455
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
456
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
457
+ let resolvedFile;
458
+ try {
459
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
460
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
461
+ : undefined;
462
+ } catch {}
463
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
464
+ try {
465
+ for (const base of localBases(specifier, parentDirectory)) {
466
+ recordPackageManifests(base);
467
+ let exact = false;
468
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
469
+ if (exact) recordInput(base);
470
+ else recordModuleCandidates(base);
471
+ }
472
+ } catch {}
473
+ return;
474
+ }
475
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
476
+ const parts = specifier.split("/");
477
+ const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
478
+ if (packageParts.some((part) => part === undefined || part === "")) return;
479
+ const packageName = packageParts.join("/");
480
+ const subpath = parts.slice(packageParts.length);
481
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
482
+ for (const searchPath of searchPaths) {
483
+ const packageDirectory = path.join(searchPath, packageName);
484
+ recordModuleCandidates(packageDirectory);
485
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
486
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
487
+ }
488
+ }
489
+
490
+ recordFile(process.argv[1]);
491
+ registerHooks({
492
+ resolve(specifier, context, nextResolve) {
493
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
494
+ const resolved = nextResolve(specifier, context);
495
+ const url = typeof resolved === "string" ? resolved : resolved && resolved.url;
496
+ recordResolutionCandidates(specifier, context.parentURL, url);
497
+ if (typeof url === "string" && url.startsWith("file:")) {
498
+ recordFile(fileURLToPath(url));
499
+ }
500
+ return resolved;
501
+ },
502
+ });
194
503
 
195
504
  (async () => {
196
505
  const mod = await import(pathToFileURL(process.argv[1]).href);
@@ -206,15 +515,11 @@ const { pathToFileURL } = require("node:url");
206
515
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
207
516
  throw new Error("strip config file must export an object");
208
517
  }
209
- process.stdout.write(JSON.stringify(value));
518
+ const serializedValue = JSON.stringify(value);
519
+ for (const input of [...inputs]) recordInput(input);
520
+ process.stdout.write(JSON.stringify({ value: JSON.parse(serializedValue), hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
210
521
  })().catch((error) => {
211
522
  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
523
  process.exitCode = 1;
219
524
  process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
220
525
  });
@@ -224,13 +529,26 @@ const { pathToFileURL } = require("node:url");
224
529
  // Node subprocess that dynamic-imports the file, resolves the default export,
225
530
  // and serializes the result as JSON to stdout.
226
531
  func loadStripScriptConfigFile(location string) (any, error) {
532
+ loaded, err := loadStripScriptConfigFileWithInputs(location)
533
+ return loaded.value, err
534
+ }
535
+
536
+ func loadStripScriptConfigFileWithInputs(location string) (stripLoadedConfig, error) {
227
537
  node := os.Getenv("TTSC_NODE_BINARY")
228
538
  if node == "" {
229
539
  node = "node"
230
540
  }
231
541
  ctx, cancel := context.WithCancel(context.Background())
232
542
  defer cancel()
233
- cmd := exec.CommandContext(ctx, node, "-e", stripScriptLoaderSource, location)
543
+ // Windows limits the whole process command line to roughly 32 KiB. The
544
+ // dependency-tracking loader is intentionally larger than that, so keep only
545
+ // an explicit CommonJS stdin program and remove Node's stdin sentinel before
546
+ // the loader runs. This preserves the historical process.argv layout seen by
547
+ // both the loader and the imported user config without using string eval.
548
+ cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
549
+ cmd.Stdin = strings.NewReader(
550
+ "process.argv.splice(1, 1);\n" + stripScriptLoaderSource,
551
+ )
234
552
  cmd.Env = stripNodeConfigLoaderEnv(location)
235
553
  // The child's stderr is human output and goes straight to this process's
236
554
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -243,15 +561,46 @@ func loadStripScriptConfigFile(location string) (any, error) {
243
561
  // What it could not put there is a reason a caller can act on, so that
244
562
  // arrives through the payload channel instead.
245
563
  if reason := loaderFailureReason(output); reason != "" {
246
- return nil, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, reason)
564
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, reason)
247
565
  }
248
- return nil, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
566
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
249
567
  }
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)
568
+ loaded, err := decodeStripConfigLoaderOutput(output)
569
+ if err != nil {
570
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: parse config file %s output: %w", location, err)
253
571
  }
254
- return out, nil
572
+ return loaded, nil
573
+ }
574
+
575
+ func decodeStripConfigLoaderOutput(output []byte) (stripLoadedConfig, error) {
576
+ var envelope struct {
577
+ Error string `json:"__ttscLoaderError"`
578
+ Hashes map[string]*string `json:"hashes"`
579
+ Inputs []string `json:"inputs"`
580
+ Realpaths map[string]*string `json:"realpaths"`
581
+ Value json.RawMessage `json:"value"`
582
+ }
583
+ if err := json.Unmarshal(output, &envelope); err != nil {
584
+ return stripLoadedConfig{}, err
585
+ }
586
+ if envelope.Error != "" {
587
+ return stripLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
588
+ }
589
+ if len(envelope.Value) == 0 {
590
+ // Test/fallback launchers written against the historical payload return
591
+ // the config value directly. Preserve that accepted contract while real
592
+ // loaders use the envelope to carry runtime inputs.
593
+ var value any
594
+ if err := json.Unmarshal(output, &value); err != nil {
595
+ return stripLoadedConfig{}, err
596
+ }
597
+ return stripLoadedConfig{value: value}, nil
598
+ }
599
+ var value any
600
+ if err := json.Unmarshal(envelope.Value, &value); err != nil {
601
+ return stripLoadedConfig{}, err
602
+ }
603
+ return stripLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
255
604
  }
256
605
 
257
606
  // stripTypeScriptLoaderSource returns the TypeScript source of the ephemeral
@@ -259,7 +608,217 @@ func loadStripScriptConfigFile(location string) (any, error) {
259
608
  // importLiteral must be a JSON-encoded relative import path (e.g.
260
609
  // `"./strip.config.ts"`) produced by json.Marshal.
261
610
  func stripTypeScriptLoaderSource(importLiteral string) string {
262
- return fmt.Sprintf(`import * as importedConfig from %s;
611
+ return fmt.Sprintf(`// @ts-nocheck
612
+ import { createRequire, isBuiltin, registerHooks } from "node:module";
613
+ import crypto from "node:crypto";
614
+ import fs from "node:fs";
615
+ import path from "node:path";
616
+ import { fileURLToPath } from "node:url";
617
+
618
+ const inputs = new Set<string>();
619
+ const hashes = new Map<string, string | null>();
620
+ const realpaths = new Map<string, string | null>();
621
+ const signatures = new Map<string, string>();
622
+ const unstableHashes = new Set<string>();
623
+
624
+ function existingFile(file: string): boolean {
625
+ try { return fs.statSync(file).isFile(); }
626
+ catch { return false; }
627
+ }
628
+
629
+ function missingPathError(error: unknown): boolean {
630
+ const code = (error as { code?: unknown } | undefined)?.code;
631
+ return code === "ENOENT" || code === "ENOTDIR";
632
+ }
633
+
634
+ function inputMetadataSignature(file: string): string | undefined {
635
+ const requested = path.resolve(file);
636
+ let current = requested;
637
+ for (;;) {
638
+ try {
639
+ const link = fs.lstatSync(current, { bigint: true });
640
+ let target = link;
641
+ if (link.isSymbolicLink()) {
642
+ try { target = fs.statSync(current, { bigint: true }); }
643
+ catch { return undefined; }
644
+ }
645
+ 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(":");
646
+ } catch (error) {
647
+ if (!missingPathError(error)) return undefined;
648
+ const parent = path.dirname(current);
649
+ if (parent === current) return undefined;
650
+ current = parent;
651
+ }
652
+ }
653
+ }
654
+
655
+ function recordInput(file: string): void {
656
+ file = path.resolve(file);
657
+ inputs.add(file);
658
+ if (unstableHashes.has(file)) return;
659
+ const beforeSignature = inputMetadataSignature(file);
660
+ let observed: string | null;
661
+ let observedRealpath: string | null;
662
+ 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"); }
663
+ catch { observed = null; }
664
+ try { observedRealpath = fs.realpathSync.native(file); }
665
+ catch { observedRealpath = null; }
666
+ const afterSignature = inputMetadataSignature(file);
667
+ 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)) {
668
+ hashes.delete(file);
669
+ realpaths.delete(file);
670
+ signatures.delete(file);
671
+ unstableHashes.add(file);
672
+ return;
673
+ }
674
+ signatures.set(file, afterSignature);
675
+ hashes.set(file, observed);
676
+ realpaths.set(file, observedRealpath);
677
+ }
678
+
679
+ function recordFile(file: string): void {
680
+ const resolvedFile = path.resolve(file);
681
+ recordInput(resolvedFile);
682
+ for (let directory = path.dirname(resolvedFile);;) {
683
+ const manifest = path.join(directory, "package.json");
684
+ recordInput(manifest);
685
+ if (existingFile(manifest)) {
686
+ break;
687
+ }
688
+ const parent = path.dirname(directory);
689
+ if (parent === directory) {
690
+ break;
691
+ }
692
+ directory = parent;
693
+ }
694
+ }
695
+
696
+ function recordPackageManifests(file: string): void {
697
+ for (let directory = path.dirname(path.resolve(file));;) {
698
+ const manifest = path.join(directory, "package.json");
699
+ recordInput(manifest);
700
+ if (existingFile(manifest)) return;
701
+ const parent = path.dirname(directory);
702
+ if (parent === directory) return;
703
+ directory = parent;
704
+ }
705
+ }
706
+
707
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] as const;
708
+ const jsToTsProbeExtensions = new Map<string, readonly string[]>([
709
+ [".js", [".ts", ".tsx"]],
710
+ [".jsx", [".tsx"]],
711
+ [".mjs", [".mts"]],
712
+ [".cjs", [".cts"]],
713
+ ]);
714
+ function sourceSubstitutionCandidates(base: string): string[] {
715
+ const extension = path.extname(base).toLowerCase();
716
+ const substitutions = jsToTsProbeExtensions.get(extension);
717
+ if (substitutions === undefined) return [];
718
+ const stem = base.slice(0, base.length - extension.length);
719
+ return substitutions.map((candidate) => stem + candidate);
720
+ }
721
+ function moduleCandidates(base: string): string[] {
722
+ return [
723
+ base,
724
+ ...sourceSubstitutionCandidates(base),
725
+ ...moduleProbeExtensions.map((extension) => base + extension),
726
+ path.join(base, "package.json"),
727
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
728
+ ];
729
+ }
730
+ const recordedModuleBases = new Set<string>();
731
+ function recordManifestTargets(value: unknown, directory: string, allowBare: boolean = false): void {
732
+ if (typeof value === "string") {
733
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
734
+ return;
735
+ }
736
+ if (Array.isArray(value)) {
737
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
738
+ return;
739
+ }
740
+ if (value !== null && typeof value === "object") {
741
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
742
+ }
743
+ }
744
+ function recordModuleCandidates(base: string): void {
745
+ const resolvedBase = path.resolve(base);
746
+ if (recordedModuleBases.has(resolvedBase)) return;
747
+ recordedModuleBases.add(resolvedBase);
748
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
749
+ try {
750
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
751
+ recordManifestTargets(manifest.exports, resolvedBase);
752
+ recordManifestTargets(manifest.module, resolvedBase, true);
753
+ recordManifestTargets(manifest.main, resolvedBase, true);
754
+ } catch {}
755
+ }
756
+ function candidateSelected(base: string, resolvedFile: string): boolean {
757
+ for (const candidate of moduleCandidates(base)) {
758
+ try {
759
+ const canonical = fs.realpathSync.native(candidate);
760
+ const relative = path.relative(canonical, resolvedFile);
761
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
762
+ } catch {}
763
+ }
764
+ return false;
765
+ }
766
+ function localBases(specifier: string, parentDirectory: string): string[] {
767
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
768
+ const raw = path.resolve(parentDirectory, specifier);
769
+ const suffixStart = specifier.search(/[?#]/);
770
+ if (suffixStart === -1) return [raw];
771
+ const pathname = specifier.slice(0, suffixStart);
772
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
773
+ }
774
+ function recordResolutionCandidates(specifier: string, parentURL: string | undefined, resolvedURL: string | undefined): void {
775
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
776
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
777
+ let resolvedFile: string | undefined;
778
+ try {
779
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
780
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
781
+ : undefined;
782
+ } catch {}
783
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
784
+ try {
785
+ for (const base of localBases(specifier, parentDirectory)) {
786
+ recordPackageManifests(base);
787
+ let exact = false;
788
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
789
+ if (exact) recordInput(base);
790
+ else recordModuleCandidates(base);
791
+ }
792
+ } catch {}
793
+ return;
794
+ }
795
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
796
+ const parts = specifier.split("/");
797
+ const packageParts = parts[0]!.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
798
+ if (packageParts.some((part) => part === undefined || part === "")) return;
799
+ const packageName = packageParts.join("/");
800
+ const subpath = parts.slice(packageParts.length);
801
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
802
+ for (const searchPath of searchPaths) {
803
+ const packageDirectory = path.join(searchPath, packageName);
804
+ recordModuleCandidates(packageDirectory);
805
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
806
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
807
+ }
808
+ }
809
+
810
+ registerHooks({
811
+ resolve(specifier, context, nextResolve) {
812
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
813
+ const resolved = nextResolve(specifier, context);
814
+ const url = typeof resolved === "string" ? resolved : resolved?.url;
815
+ recordResolutionCandidates(specifier, context.parentURL, url);
816
+ if (typeof url === "string" && url.startsWith("file:")) {
817
+ recordFile(fileURLToPath(url));
818
+ }
819
+ return resolved;
820
+ },
821
+ });
263
822
 
264
823
  declare const process: {
265
824
  exitCode?: number;
@@ -275,6 +834,7 @@ declare const process: {
275
834
  // left for a trailing handler to settle.
276
835
  (async () => {
277
836
  try {
837
+ const importedConfig = await import(%s);
278
838
  let current: unknown = importedConfig;
279
839
  for (let i = 0; i < 8; i++) {
280
840
  if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current as Record<string, unknown>, "default")) {
@@ -289,7 +849,9 @@ declare const process: {
289
849
  if (current === null || typeof current !== "object" || Array.isArray(current)) {
290
850
  throw new Error("strip config file must export an object");
291
851
  }
292
- process.stdout.write(JSON.stringify(current));
852
+ const serializedValue = JSON.stringify(current);
853
+ for (const input of [...inputs]) recordInput(input);
854
+ process.stdout.write(JSON.stringify({ value: JSON.parse(serializedValue), hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
293
855
  } catch (error) {
294
856
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
295
857
  // The stack above is for the reader. This is for the caller: the parent
@@ -321,31 +883,36 @@ declare const process: {
321
883
  // resolved from the project rather than from the process environment alone;
322
884
  // see stripConfigToolAnchors.
323
885
  func loadStripTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
886
+ loaded, err := loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot)
887
+ return loaded.value, err
888
+ }
889
+
890
+ func loadStripTypeScriptConfigFileWithInputs(location, resolutionRoot string) (stripLoadedConfig, error) {
324
891
  tempDir, err := os.MkdirTemp(stripLoaderTempBase(location, os.TempDir()), "ttsc-strip-config-")
325
892
  if err != nil {
326
- return nil, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
893
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
327
894
  }
328
895
  defer os.RemoveAll(tempDir)
329
896
 
330
897
  if err := stripLinkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
331
- return nil, err
898
+ return stripLoadedConfig{}, err
332
899
  }
333
900
 
334
901
  loader := filepath.Join(tempDir, "loader.mts")
335
902
  tsconfig := filepath.Join(tempDir, "tsconfig.json")
336
903
  importSpecifier, err := stripRelativeImportSpecifier(tempDir, location)
337
904
  if err != nil {
338
- return nil, err
905
+ return stripLoadedConfig{}, err
339
906
  }
340
907
  importLiteral, err := json.Marshal(importSpecifier)
341
908
  if err != nil {
342
- return nil, fmt.Errorf("@ttsc/strip: encode config import %s: %w", location, err)
909
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: encode config import %s: %w", location, err)
343
910
  }
344
911
  if err := os.WriteFile(loader, []byte(stripTypeScriptLoaderSource(string(importLiteral))), 0o644); err != nil {
345
- return nil, fmt.Errorf("@ttsc/strip: write config loader: %w", err)
912
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: write config loader: %w", err)
346
913
  }
347
914
  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)
915
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: write config loader tsconfig: %w", err)
349
916
  }
350
917
 
351
918
  args := []string{
@@ -375,15 +942,15 @@ func loadStripTypeScriptConfigFile(location, resolutionRoot string) (any, error)
375
942
  // What it could not put there is a reason a caller can act on, so that
376
943
  // arrives through the payload channel instead.
377
944
  if reason := loaderFailureReason(output); reason != "" {
378
- return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, reason)
945
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, reason)
379
946
  }
380
- return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
947
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
381
948
  }
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)
949
+ loaded, err := decodeStripConfigLoaderOutput(output)
950
+ if err != nil {
951
+ return stripLoadedConfig{}, fmt.Errorf("@ttsc/strip: parse TypeScript config file %s output: %w", location, err)
385
952
  }
386
- return out, nil
953
+ return loaded, nil
387
954
  }
388
955
 
389
956
  // stripTypeScriptLoaderTsconfig generates the JSON content of the ephemeral
@@ -402,6 +969,7 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
402
969
  // resolving either way.
403
970
  "module": stripConfigModuleOption(location),
404
971
  "moduleResolution": "bundler",
972
+ "jsx": "preserve",
405
973
  "noImplicitAny": false,
406
974
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
407
975
  "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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/strip",
3
- "version": "0.26.2",
3
+ "version": "0.27.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";