@ttsc/strip 0.26.1 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/driver/config.go +264 -11
  2. package/package.json +1 -1
package/driver/config.go CHANGED
@@ -55,6 +55,10 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
55
55
  }
56
56
  }
57
57
 
58
+ // The discovery base directory doubles as the resolution root the config
59
+ // loader anchors its toolchain lookup on; see stripConfigToolAnchors.
60
+ resolutionRoot := stripDiscoveryBaseDir(cwd, tsconfigPath)
61
+
58
62
  // Resolve the config file: explicit configFile wins over discovery.
59
63
  configFilePath := ""
60
64
  if rawCF, ok := pluginConfig["configFile"]; ok {
@@ -77,7 +81,7 @@ func loadStripConfigMap(pluginConfig map[string]any, cwd, tsconfigPath string) (
77
81
  return map[string]any{}, nil
78
82
  }
79
83
 
80
- raw, err := loadStripConfigFile(configFilePath)
84
+ raw, err := loadStripConfigFile(configFilePath, resolutionRoot)
81
85
  if err != nil {
82
86
  return nil, err
83
87
  }
@@ -147,7 +151,12 @@ func resolveStripConfigFilePath(configPath, cwd, tsconfigPath string) string {
147
151
  // loadStripConfigFile loads and deserializes a strip config file at location.
148
152
  // The format is determined by extension: .json is parsed natively; .js/.cjs/.mjs
149
153
  // run through a Node subprocess; .ts/.cts/.mts run through ttsx.
150
- func loadStripConfigFile(location string) (any, error) {
154
+ //
155
+ // resolutionRoot is the project directory the TypeScript branch anchors its
156
+ // toolchain resolution on when the config file's own ancestry answers nothing;
157
+ // see stripConfigToolAnchors. The JSON and JS branches spawn no ttsx and
158
+ // ignore it.
159
+ func loadStripConfigFile(location, resolutionRoot string) (any, error) {
151
160
  ext := strings.ToLower(filepath.Ext(location))
152
161
  switch ext {
153
162
  case ".json":
@@ -155,7 +164,7 @@ func loadStripConfigFile(location string) (any, error) {
155
164
  case ".js", ".cjs", ".mjs":
156
165
  return loadStripScriptConfigFile(location)
157
166
  case ".ts", ".cts", ".mts":
158
- return loadStripTypeScriptConfigFile(location)
167
+ return loadStripTypeScriptConfigFile(location, resolutionRoot)
159
168
  default:
160
169
  return nil, fmt.Errorf("@ttsc/strip: unsupported config file extension %q for %s", ext, location)
161
170
  }
@@ -307,7 +316,11 @@ declare const process: {
307
316
  // type-check and execute the strip config file, so loading the host
308
317
  // project's transform/check plugins would be wasteful and could fail the
309
318
  // build against this deliberately lenient loader tsconfig.
310
- func loadStripTypeScriptConfigFile(location string) (any, error) {
319
+ //
320
+ // Both tools this spawns — the launcher and the compiler handed to it — are
321
+ // resolved from the project rather than from the process environment alone;
322
+ // see stripConfigToolAnchors.
323
+ func loadStripTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
311
324
  tempDir, err := os.MkdirTemp(stripLoaderTempBase(location, os.TempDir()), "ttsc-strip-config-")
312
325
  if err != nil {
313
326
  return nil, fmt.Errorf("@ttsc/strip: create config loader tempdir: %w", err)
@@ -341,14 +354,15 @@ func loadStripTypeScriptConfigFile(location string) (any, error) {
341
354
  "--cache-dir", filepath.Join(tempDir, "cache"),
342
355
  "--no-plugins",
343
356
  }
344
- if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
357
+ anchors := stripConfigToolAnchors(location, resolutionRoot)
358
+ if tsgo := stripResolveConfigTsgo(anchors); tsgo != "" {
345
359
  args = append(args, "--binary", tsgo)
346
360
  }
347
361
  args = append(args, loader)
348
362
 
349
363
  ctx, cancel := context.WithCancel(context.Background())
350
364
  defer cancel()
351
- cmd := stripTtsxCommandContext(ctx, args...)
365
+ cmd := stripTtsxCommandContext(ctx, anchors, args...)
352
366
  cmd.Env = stripNodeConfigLoaderEnv(location)
353
367
  // The child's stderr is human output and goes straight to this process's
354
368
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -414,6 +428,23 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
414
428
  return string(body)
415
429
  }
416
430
 
431
+ // ttsc:config-loader-shared begin
432
+ //
433
+ // One policy in three Go copies: everything between these markers is
434
+ // duplicated verbatim in packages/lint/linthost/config.go,
435
+ // packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
436
+ // decided against extracting it — the only home the three modules could share
437
+ // is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
438
+ // deliberately requires no in-tree ttsc module — and replaced the checklist
439
+ // with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
440
+ // between these markers across all three copies on every pull request, so
441
+ // editing one and not the others fails by name. That file's header carries the
442
+ // full decision and the rules for changing this block.
443
+ //
444
+ // The code between the markers must stay identical. Comments may differ, the
445
+ // `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
446
+ // a `strip` prefix. Anything package-specific belongs outside the markers.
447
+
417
448
  // stripConfigModuleOption returns the loader tsconfig's "module" for a config
418
449
  // file: the module kind Node itself would give that file.
419
450
  //
@@ -536,13 +567,233 @@ func stripResolveDirLink(dir string) string {
536
567
  return dir
537
568
  }
538
569
 
570
+ // stripRealpathIfPossible resolves location through its symlinks, and returns
571
+ // it unchanged when it cannot be evaluated (a path that does not exist, or an
572
+ // NTFS junction filepath.EvalSymlinks refuses to traverse).
573
+ func stripRealpathIfPossible(location string) string {
574
+ real, err := filepath.EvalSymlinks(location)
575
+ if err != nil {
576
+ return location
577
+ }
578
+ return real
579
+ }
580
+
581
+ // Both tools the TypeScript config evaluator needs — the `ttsx` launcher it
582
+ // spawns and the native compiler it hands that launcher — are resolved from
583
+ // the project being compiled, with an explicit environment variable winning
584
+ // and a last resort that invents no path.
585
+ //
586
+ // The three Go copies are held identical by the gate named at the top of this
587
+ // block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
588
+ // packages/lint/src/index.ts — is a fourth copy in another language that no Go
589
+ // gate can reach; what it owes is that both policies stay describable in one
590
+ // sentence.
591
+ //
592
+ // The environment alone is the wrong place to ask. `ttsx` exports
593
+ // TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
594
+ // launched under `ttsx` inherited both and a host launched any other way
595
+ // inherited neither. The shipped `ttscserver` binary invoked with its
596
+ // documented `--tsgo <path>` flag keeps that path in a local and exports
597
+ // nothing, and an embedder of the driver package exports nothing either. For
598
+ // those the evaluator spawned a bare `ttsx` that only a global install puts on
599
+ // PATH, and, past that, a compiler-less child that aborted with
600
+ // `ttsc: typescript is required` before a line of the config was read.
601
+ //
602
+ // stripConfigToolAnchors lists the file paths those resolutions walk upward
603
+ // from, in order: the config file being evaluated, then the resolution root's
604
+ // manifest. The config comes first because it is the file whose own
605
+ // installation decides which toolchain the config's imports were written
606
+ // against; the resolution root answers for a config that lives outside the
607
+ // project tree (a `configFile` pointed at a shared package), and for one
608
+ // discovered above a workspace that installs its own toolchain.
609
+ func stripConfigToolAnchors(configPath, resolutionRoot string) []string {
610
+ anchors := make([]string, 0, 2)
611
+ if strings.TrimSpace(configPath) != "" {
612
+ anchors = append(anchors, configPath)
613
+ }
614
+ if strings.TrimSpace(resolutionRoot) != "" {
615
+ anchors = append(anchors, filepath.Join(resolutionRoot, "package.json"))
616
+ }
617
+ return anchors
618
+ }
619
+
620
+ // stripResolveConfigTsgo returns the native TypeScript compiler the evaluator
621
+ // hands its ttsx child through `--binary`, or "" to leave the child resolving
622
+ // for itself.
623
+ //
624
+ // The child runs with `--cwd <ephemeral loader dir>`, so it cannot discover
625
+ // `typescript` the way an ordinary invocation does: stripLinkNearestNodeModules
626
+ // is the only thing that puts the project's modules within its reach, and it
627
+ // links nothing when the config's ancestry carries no node_modules. An explicit
628
+ // TTSC_TSGO_BINARY still wins, so an embedder that pins a compiler keeps
629
+ // pinning it. "" is the unchanged last resort: a project that cannot answer
630
+ // here could not answer inside the child either, and the child's own diagnostic
631
+ // is the one that names the missing package.
632
+ func stripResolveConfigTsgo(anchors []string) string {
633
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TSGO_BINARY")); explicit != "" {
634
+ return explicit
635
+ }
636
+ for _, anchor := range anchors {
637
+ if binary := stripTsgoBinaryFrom(anchor); binary != "" {
638
+ return binary
639
+ }
640
+ }
641
+ return ""
642
+ }
643
+
644
+ // stripTsgoBinaryFrom returns the platform compiler executable of the
645
+ // `typescript` install `anchor` can see, or "" when this anchor reaches neither
646
+ // the package nor its platform dependency.
647
+ //
648
+ // Mirrors resolveTsgo.ts so the Go plugin and the JS launcher name one file:
649
+ // the `typescript` manifest, then `@typescript/typescript-<platform>-<arch>`
650
+ // resolved from that manifest, then `lib/tsc` inside it.
651
+ //
652
+ // The install is chased to its real directory before the second hop, because
653
+ // Node resolves a module's own dependencies from its real location. pnpm keeps
654
+ // the real `typescript` directory in its content-addressed store with the
655
+ // platform package beside it and leaves a link in the project's node_modules,
656
+ // so a walk that started at the link would climb straight past the platform
657
+ // package. NTFS junctions defeat filepath.EvalSymlinks, so the link component
658
+ // is chased by hand first, the same order stripLoaderTempBase uses.
659
+ func stripTsgoBinaryFrom(anchor string) string {
660
+ manifest := stripNodePackageManifestFrom(anchor, "typescript")
661
+ if manifest == "" {
662
+ return ""
663
+ }
664
+ packageDir := stripRealpathIfPossible(stripResolveDirLink(filepath.Dir(manifest)))
665
+ platform, arch := stripNodePlatformPair()
666
+ platformManifest := stripNodePackageManifestFrom(
667
+ filepath.Join(packageDir, "package.json"),
668
+ "@typescript/typescript-"+platform+"-"+arch,
669
+ )
670
+ if platformManifest == "" {
671
+ return ""
672
+ }
673
+ name := "tsc"
674
+ if runtime.GOOS == "windows" {
675
+ name = "tsc.exe"
676
+ }
677
+ binary := filepath.Join(filepath.Dir(platformManifest), "lib", name)
678
+ if stat, err := os.Stat(binary); err != nil || stat.IsDir() {
679
+ return ""
680
+ }
681
+ return binary
682
+ }
683
+
684
+ // stripResolveTtsxLauncher returns the launcher stripTtsxCommandContext spawns.
685
+ //
686
+ // An explicit TTSC_TTSX_BINARY wins. Otherwise the launcher is derived from the
687
+ // `ttsc` installation one of the anchors can see, because a bare command name
688
+ // only works when a bin link happens to be on PATH — which it is for a global
689
+ // install and is not for the ordinary project-local one. The bare `"ttsx"` name
690
+ // remains the unchanged last resort for an installation no anchor reaches.
691
+ func stripResolveTtsxLauncher(anchors []string) string {
692
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TTSX_BINARY")); explicit != "" {
693
+ return explicit
694
+ }
695
+ for _, anchor := range anchors {
696
+ if launcher := stripTtsxLauncherFrom(anchor); launcher != "" {
697
+ return launcher
698
+ }
699
+ }
700
+ return "ttsx"
701
+ }
702
+
703
+ // stripTtsxLauncherFrom returns `lib/launcher/ttsx.js` of the `ttsc` install
704
+ // `anchor` can see, or "" when this anchor reaches no such install. Only the
705
+ // manifest is an exported subpath, so the launcher is derived from where the
706
+ // manifest resolved rather than requested as a subpath of its own.
707
+ func stripTtsxLauncherFrom(anchor string) string {
708
+ manifest := stripNodePackageManifestFrom(anchor, "ttsc")
709
+ if manifest == "" {
710
+ return ""
711
+ }
712
+ launcher := filepath.Join(filepath.Dir(manifest), "lib", "launcher", "ttsx.js")
713
+ if stat, err := os.Stat(launcher); err != nil || stat.IsDir() {
714
+ return ""
715
+ }
716
+ return launcher
717
+ }
718
+
719
+ // stripNodePackageManifestFrom resolves `<pkg>/package.json` the way Node's
720
+ // require.resolve does from the FILE `anchor`: walk upward from the anchor's
721
+ // directory and return the first `<dir>/node_modules/<pkg>/package.json` that
722
+ // exists. The anchor is treated as a file path, so its own directory is the
723
+ // first candidate's parent, and it need not exist — Node derives the search
724
+ // paths from the string alone.
725
+ //
726
+ // A directory already named `node_modules` contributes no candidate of its own,
727
+ // matching Module._nodeModulePaths, so nothing ever resolves through
728
+ // `node_modules/node_modules`.
729
+ //
730
+ // A relative anchor is resolved against the process directory before the walk,
731
+ // again matching Node. Walking a relative path instead would terminate at "."
732
+ // after one step and silently answer nothing for a config named relatively.
733
+ func stripNodePackageManifestFrom(anchor, pkg string) string {
734
+ if strings.TrimSpace(anchor) == "" || pkg == "" {
735
+ return ""
736
+ }
737
+ if absolute, err := filepath.Abs(anchor); err == nil {
738
+ anchor = absolute
739
+ }
740
+ dir := filepath.Dir(filepath.Clean(anchor))
741
+ for {
742
+ if filepath.Base(dir) != "node_modules" {
743
+ candidate := filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")
744
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
745
+ return candidate
746
+ }
747
+ }
748
+ parent := filepath.Dir(dir)
749
+ if parent == dir {
750
+ return ""
751
+ }
752
+ dir = parent
753
+ }
754
+ }
755
+
756
+ // stripNodePlatformPair is stripNodePlatformPairFor applied to this build's own
757
+ // target.
758
+ func stripNodePlatformPair() (string, string) {
759
+ return stripNodePlatformPairFor(runtime.GOOS, runtime.GOARCH)
760
+ }
761
+
762
+ // stripNodePlatformPairFor maps a Go build target onto the `process.platform`
763
+ // and `process.arch` pair npm spells a platform package with, so the package
764
+ // name this plugin resolves is the same one the JS launcher resolves.
765
+ //
766
+ // Only the members whose two vocabularies disagree are mapped. Every other
767
+ // value is identical on both sides and passes through, which keeps a target
768
+ // neither side publishes yet resolvable rather than silently wrong, and keeps
769
+ // this from becoming a list that has to grow with every new port.
770
+ func stripNodePlatformPairFor(goos, goarch string) (string, string) {
771
+ platform := goos
772
+ switch platform {
773
+ case "windows":
774
+ platform = "win32"
775
+ case "solaris":
776
+ platform = "sunos"
777
+ }
778
+ arch := goarch
779
+ switch arch {
780
+ case "amd64":
781
+ arch = "x64"
782
+ case "386":
783
+ arch = "ia32"
784
+ case "ppc64le":
785
+ arch = "ppc64"
786
+ }
787
+ return platform, arch
788
+ }
789
+
539
790
  // stripTtsxCommandContext returns an exec.Cmd that runs ttsx with the given
540
791
  // arguments, routing through node when the resolved binary is a script file.
541
- func stripTtsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
542
- ttsx := os.Getenv("TTSC_TTSX_BINARY")
543
- if ttsx == "" {
544
- ttsx = "ttsx"
545
- }
792
+ //
793
+ // `anchors` are the file paths the launcher is resolved from; see
794
+ // stripResolveTtsxLauncher.
795
+ func stripTtsxCommandContext(ctx context.Context, anchors []string, args ...string) *exec.Cmd {
796
+ ttsx := stripResolveTtsxLauncher(anchors)
546
797
  if stripShouldRunThroughNode(ttsx) {
547
798
  node := os.Getenv("TTSC_NODE_BINARY")
548
799
  if node == "" {
@@ -673,3 +924,5 @@ func loaderFailureReason(output []byte) string {
673
924
  }
674
925
  return strings.TrimSpace(envelope.Message)
675
926
  }
927
+
928
+ // ttsc:config-loader-shared end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/strip",
3
- "version": "0.26.1",
3
+ "version": "0.26.2",
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",