@ttsc/banner 0.26.0 → 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/banner.go +286 -35
  2. package/package.json +2 -2
package/driver/banner.go CHANGED
@@ -92,16 +92,26 @@ func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error
92
92
  return b.String(), nil
93
93
  }
94
94
 
95
+ // sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
96
+ // by replacing "*/" with "* /" so the generated block comment stays valid.
97
+ func sanitizeJSDocLine(line string) string {
98
+ return strings.ReplaceAll(line, "*/", "* /")
99
+ }
100
+
95
101
  // resolveBannerText extracts the banner text from the plugin config.
96
102
  // The config entry is validated first: only the "configFile" key (plus
97
103
  // framework keys) is accepted. When "configFile" is present its value is
98
104
  // resolved to an absolute path and loaded. When absent the upward-walk
99
105
  // discovery is used. Returns an error when the config is invalid or when
100
106
  // no banner text can be found.
107
+ //
108
+ // The discovery base directory doubles as the resolution root the config
109
+ // loader anchors its toolchain lookup on; see configToolAnchors.
101
110
  func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string, error) {
102
111
  if err := validateBannerConfig(config); err != nil {
103
112
  return "", err
104
113
  }
114
+ resolutionRoot := tsconfigBaseDir(cwd, tsconfigPath)
105
115
 
106
116
  if rawConfigFile, ok := config["configFile"]; ok {
107
117
  configFile, ok := rawConfigFile.(string)
@@ -109,7 +119,7 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
109
119
  return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
110
120
  }
111
121
  location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
112
- raw, err := loadBannerConfigFile(location)
122
+ raw, err := loadBannerConfigFile(location, resolutionRoot)
113
123
  if err != nil {
114
124
  return "", err
115
125
  }
@@ -130,7 +140,7 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
130
140
  if location == "" {
131
141
  return "", fmt.Errorf("@ttsc/banner: no banner.config.{ts,cts,mts,js,cjs,mjs,json} file found; create one or set \"configFile\" in the tsconfig plugin entry")
132
142
  }
133
- raw, err := loadBannerConfigFile(location)
143
+ raw, err := loadBannerConfigFile(location, resolutionRoot)
134
144
  if err != nil {
135
145
  return "", err
136
146
  }
@@ -238,7 +248,11 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
238
248
  // must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}; JS/CJS/MJS variants
239
249
  // run under Node, TypeScript variants compile and run via ttsx in a temp
240
250
  // directory, and JSON files are parsed natively.
241
- func loadBannerConfigFile(location string) (any, error) {
251
+ //
252
+ // resolutionRoot is the project directory the TypeScript branch anchors its
253
+ // toolchain resolution on when the config file's own ancestry answers nothing;
254
+ // see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
255
+ func loadBannerConfigFile(location, resolutionRoot string) (any, error) {
242
256
  if !isBannerConfigFileName(filepath.Base(location)) {
243
257
  return nil, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
244
258
  }
@@ -249,7 +263,7 @@ func loadBannerConfigFile(location string) (any, error) {
249
263
  case ".js", ".cjs", ".mjs":
250
264
  return loadBannerScriptConfigFile(location)
251
265
  }
252
- return loadBannerTypeScriptConfigFile(location)
266
+ return loadBannerTypeScriptConfigFile(location, resolutionRoot)
253
267
  }
254
268
 
255
269
  // isBannerConfigFileName reports whether name is an allowed banner config file name.
@@ -362,7 +376,11 @@ function toSerializableBanner(value) {
362
376
  // is created so the config file can import its own dependencies. The ttsx
363
377
  // build runs with `--no-plugins` so evaluating the config never triggers the
364
378
  // host project's transform/check plugins against the loader tsconfig.
365
- func loadBannerTypeScriptConfigFile(location string) (any, error) {
379
+ //
380
+ // Both tools this spawns — the launcher and the compiler handed to it — are
381
+ // resolved from the project rather than from the process environment alone;
382
+ // see configToolAnchors.
383
+ func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
366
384
  tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
367
385
  if err != nil {
368
386
  return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
@@ -393,14 +411,15 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
393
411
  "--cache-dir", filepath.Join(tempDir, "cache"),
394
412
  "--no-plugins",
395
413
  }
396
- if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
414
+ anchors := configToolAnchors(location, resolutionRoot)
415
+ if tsgo := resolveConfigTsgo(anchors); tsgo != "" {
397
416
  args = append(args, "--binary", tsgo)
398
417
  }
399
418
  args = append(args, loader)
400
419
 
401
420
  ctx, cancel := context.WithCancel(context.Background())
402
421
  defer cancel()
403
- cmd := ttsxCommandContext(ctx, args...)
422
+ cmd := ttsxCommandContext(ctx, anchors, args...)
404
423
  cmd.Env = nodeConfigLoaderEnv(location)
405
424
  // The child's stderr is human output and goes straight to this process's
406
425
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -424,20 +443,6 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
424
443
  return out, nil
425
444
  }
426
445
 
427
- // relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
428
- // import specifier for location relative to fromDir.
429
- func relativeImportSpecifier(fromDir, location string) (string, error) {
430
- relative, err := filepath.Rel(fromDir, location)
431
- if err != nil {
432
- return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
433
- }
434
- relative = filepath.ToSlash(relative)
435
- if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
436
- return relative, nil
437
- }
438
- return "./" + relative, nil
439
- }
440
-
441
446
  // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
442
447
  // module that imports the banner config file specified by importLiteral (a
443
448
  // JSON-encoded import specifier) and writes the serialized banner value to stdout.
@@ -551,6 +556,23 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
551
556
  return string(body)
552
557
  }
553
558
 
559
+ // ttsc:config-loader-shared begin
560
+ //
561
+ // One policy in three Go copies: everything between these markers is
562
+ // duplicated verbatim in packages/lint/linthost/config.go,
563
+ // packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
564
+ // decided against extracting it — the only home the three modules could share
565
+ // is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
566
+ // deliberately requires no in-tree ttsc module — and replaced the checklist
567
+ // with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
568
+ // between these markers across all three copies on every pull request, so
569
+ // editing one and not the others fails by name. That file's header carries the
570
+ // full decision and the rules for changing this block.
571
+ //
572
+ // The code between the markers must stay identical. Comments may differ, the
573
+ // `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
574
+ // a `strip` prefix. Anything package-specific belongs outside the markers.
575
+
554
576
  // configModuleOption returns the loader tsconfig's "module" for a config file:
555
577
  // the module kind Node itself would give that file.
556
578
  //
@@ -673,21 +695,240 @@ func resolveDirLink(dir string) string {
673
695
  return dir
674
696
  }
675
697
 
698
+ // realpathIfPossible resolves location through its symlinks, and returns it
699
+ // unchanged when it cannot be evaluated (a path that does not exist, or an
700
+ // NTFS junction filepath.EvalSymlinks refuses to traverse).
701
+ func realpathIfPossible(location string) string {
702
+ real, err := filepath.EvalSymlinks(location)
703
+ if err != nil {
704
+ return location
705
+ }
706
+ return real
707
+ }
708
+
709
+ // Both tools the TypeScript config evaluator needs — the `ttsx` launcher it
710
+ // spawns and the native compiler it hands that launcher — are resolved from
711
+ // the project being compiled, with an explicit environment variable winning
712
+ // and a last resort that invents no path.
713
+ //
714
+ // The three Go copies are held identical by the gate named at the top of this
715
+ // block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
716
+ // packages/lint/src/index.ts — is a fourth copy in another language that no Go
717
+ // gate can reach; what it owes is that both policies stay describable in one
718
+ // sentence.
719
+ //
720
+ // The environment alone is the wrong place to ask. `ttsx` exports
721
+ // TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
722
+ // launched under `ttsx` inherited both and a host launched any other way
723
+ // inherited neither. The shipped `ttscserver` binary invoked with its
724
+ // documented `--tsgo <path>` flag keeps that path in a local and exports
725
+ // nothing, and an embedder of the driver package exports nothing either. For
726
+ // those the evaluator spawned a bare `ttsx` that only a global install puts on
727
+ // PATH, and, past that, a compiler-less child that aborted with
728
+ // `ttsc: typescript is required` before a line of the config was read.
729
+ //
730
+ // configToolAnchors lists the file paths those resolutions walk upward from,
731
+ // in order: the config file being evaluated, then the resolution root's
732
+ // manifest. The config comes first because it is the file whose own
733
+ // installation decides which toolchain the config's imports were written
734
+ // against; the resolution root answers for a config that lives outside the
735
+ // project tree (a `configFile` pointed at a shared package), and for one
736
+ // discovered above a workspace that installs its own toolchain.
737
+ func configToolAnchors(configPath, resolutionRoot string) []string {
738
+ anchors := make([]string, 0, 2)
739
+ if strings.TrimSpace(configPath) != "" {
740
+ anchors = append(anchors, configPath)
741
+ }
742
+ if strings.TrimSpace(resolutionRoot) != "" {
743
+ anchors = append(anchors, filepath.Join(resolutionRoot, "package.json"))
744
+ }
745
+ return anchors
746
+ }
747
+
748
+ // resolveConfigTsgo returns the native TypeScript compiler the evaluator hands
749
+ // its ttsx child through `--binary`, or "" to leave the child resolving for
750
+ // itself.
751
+ //
752
+ // The child runs with `--cwd <ephemeral loader dir>`, so it cannot discover
753
+ // `typescript` the way an ordinary invocation does: linkNearestNodeModules is
754
+ // the only thing that puts the project's modules within its reach, and it links
755
+ // nothing when the config's ancestry carries no node_modules. An explicit
756
+ // TTSC_TSGO_BINARY still wins, so an embedder that pins a compiler keeps
757
+ // pinning it. "" is the unchanged last resort: a project that cannot answer
758
+ // here could not answer inside the child either, and the child's own diagnostic
759
+ // is the one that names the missing package.
760
+ func resolveConfigTsgo(anchors []string) string {
761
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TSGO_BINARY")); explicit != "" {
762
+ return explicit
763
+ }
764
+ for _, anchor := range anchors {
765
+ if binary := tsgoBinaryFrom(anchor); binary != "" {
766
+ return binary
767
+ }
768
+ }
769
+ return ""
770
+ }
771
+
772
+ // tsgoBinaryFrom returns the platform compiler executable of the `typescript`
773
+ // install `anchor` can see, or "" when this anchor reaches neither the package
774
+ // nor its platform dependency.
775
+ //
776
+ // Mirrors resolveTsgo.ts so the Go plugin and the JS launcher name one file:
777
+ // the `typescript` manifest, then `@typescript/typescript-<platform>-<arch>`
778
+ // resolved from that manifest, then `lib/tsc` inside it.
779
+ //
780
+ // The install is chased to its real directory before the second hop, because
781
+ // Node resolves a module's own dependencies from its real location. pnpm keeps
782
+ // the real `typescript` directory in its content-addressed store with the
783
+ // platform package beside it and leaves a link in the project's node_modules,
784
+ // so a walk that started at the link would climb straight past the platform
785
+ // package. NTFS junctions defeat filepath.EvalSymlinks, so the link component
786
+ // is chased by hand first, the same order loaderTempBase uses.
787
+ func tsgoBinaryFrom(anchor string) string {
788
+ manifest := nodePackageManifestFrom(anchor, "typescript")
789
+ if manifest == "" {
790
+ return ""
791
+ }
792
+ packageDir := realpathIfPossible(resolveDirLink(filepath.Dir(manifest)))
793
+ platform, arch := nodePlatformPair()
794
+ platformManifest := nodePackageManifestFrom(
795
+ filepath.Join(packageDir, "package.json"),
796
+ "@typescript/typescript-"+platform+"-"+arch,
797
+ )
798
+ if platformManifest == "" {
799
+ return ""
800
+ }
801
+ name := "tsc"
802
+ if runtime.GOOS == "windows" {
803
+ name = "tsc.exe"
804
+ }
805
+ binary := filepath.Join(filepath.Dir(platformManifest), "lib", name)
806
+ if stat, err := os.Stat(binary); err != nil || stat.IsDir() {
807
+ return ""
808
+ }
809
+ return binary
810
+ }
811
+
812
+ // resolveTtsxLauncher returns the launcher ttsxCommandContext spawns.
813
+ //
814
+ // An explicit TTSC_TTSX_BINARY wins. Otherwise the launcher is derived from the
815
+ // `ttsc` installation one of the anchors can see, because a bare command name
816
+ // only works when a bin link happens to be on PATH — which it is for a global
817
+ // install and is not for the ordinary project-local one. The bare `"ttsx"` name
818
+ // remains the unchanged last resort for an installation no anchor reaches.
819
+ func resolveTtsxLauncher(anchors []string) string {
820
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TTSX_BINARY")); explicit != "" {
821
+ return explicit
822
+ }
823
+ for _, anchor := range anchors {
824
+ if launcher := ttsxLauncherFrom(anchor); launcher != "" {
825
+ return launcher
826
+ }
827
+ }
828
+ return "ttsx"
829
+ }
830
+
831
+ // ttsxLauncherFrom returns `lib/launcher/ttsx.js` of the `ttsc` install
832
+ // `anchor` can see, or "" when this anchor reaches no such install. Only the
833
+ // manifest is an exported subpath, so the launcher is derived from where the
834
+ // manifest resolved rather than requested as a subpath of its own.
835
+ func ttsxLauncherFrom(anchor string) string {
836
+ manifest := nodePackageManifestFrom(anchor, "ttsc")
837
+ if manifest == "" {
838
+ return ""
839
+ }
840
+ launcher := filepath.Join(filepath.Dir(manifest), "lib", "launcher", "ttsx.js")
841
+ if stat, err := os.Stat(launcher); err != nil || stat.IsDir() {
842
+ return ""
843
+ }
844
+ return launcher
845
+ }
846
+
847
+ // nodePackageManifestFrom resolves `<pkg>/package.json` the way Node's
848
+ // require.resolve does from the FILE `anchor`: walk upward from the anchor's
849
+ // directory and return the first `<dir>/node_modules/<pkg>/package.json` that
850
+ // exists. The anchor is treated as a file path, so its own directory is the
851
+ // first candidate's parent, and it need not exist — Node derives the search
852
+ // paths from the string alone.
853
+ //
854
+ // A directory already named `node_modules` contributes no candidate of its own,
855
+ // matching Module._nodeModulePaths, so nothing ever resolves through
856
+ // `node_modules/node_modules`.
857
+ //
858
+ // A relative anchor is resolved against the process directory before the walk,
859
+ // again matching Node. Walking a relative path instead would terminate at "."
860
+ // after one step and silently answer nothing for a config named relatively.
861
+ func nodePackageManifestFrom(anchor, pkg string) string {
862
+ if strings.TrimSpace(anchor) == "" || pkg == "" {
863
+ return ""
864
+ }
865
+ if absolute, err := filepath.Abs(anchor); err == nil {
866
+ anchor = absolute
867
+ }
868
+ dir := filepath.Dir(filepath.Clean(anchor))
869
+ for {
870
+ if filepath.Base(dir) != "node_modules" {
871
+ candidate := filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")
872
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
873
+ return candidate
874
+ }
875
+ }
876
+ parent := filepath.Dir(dir)
877
+ if parent == dir {
878
+ return ""
879
+ }
880
+ dir = parent
881
+ }
882
+ }
883
+
884
+ // nodePlatformPair is nodePlatformPairFor applied to this build's own target.
885
+ func nodePlatformPair() (string, string) {
886
+ return nodePlatformPairFor(runtime.GOOS, runtime.GOARCH)
887
+ }
888
+
889
+ // nodePlatformPairFor maps a Go build target onto the `process.platform` and
890
+ // `process.arch` pair npm spells a platform package with, so the package name
891
+ // this plugin resolves is the same one the JS launcher resolves.
892
+ //
893
+ // Only the members whose two vocabularies disagree are mapped. Every other
894
+ // value is identical on both sides and passes through, which keeps a target
895
+ // neither side publishes yet resolvable rather than silently wrong, and keeps
896
+ // this from becoming a list that has to grow with every new port.
897
+ func nodePlatformPairFor(goos, goarch string) (string, string) {
898
+ platform := goos
899
+ switch platform {
900
+ case "windows":
901
+ platform = "win32"
902
+ case "solaris":
903
+ platform = "sunos"
904
+ }
905
+ arch := goarch
906
+ switch arch {
907
+ case "amd64":
908
+ arch = "x64"
909
+ case "386":
910
+ arch = "ia32"
911
+ case "ppc64le":
912
+ arch = "ppc64"
913
+ }
914
+ return platform, arch
915
+ }
916
+
676
917
  // ttsxCommand builds an exec.Cmd that runs ttsx with the given args.
677
- // When TTSC_TTSX_BINARY has a script extension (.js, .ts, …) the binary is
918
+ // When the resolved launcher has a script extension (.js, .ts, …) the binary is
678
919
  // invoked via the Node runtime so it is executed correctly on all platforms.
679
- func ttsxCommand(args ...string) *exec.Cmd {
680
- return ttsxCommandContext(context.Background(), args...)
920
+ func ttsxCommand(anchors []string, args ...string) *exec.Cmd {
921
+ return ttsxCommandContext(context.Background(), anchors, args...)
681
922
  }
682
923
 
683
924
  // ttsxCommandContext is the context-bound variant used by config loaders. It
684
925
  // carries no deadline: evaluating a user config is the user's own code running,
685
926
  // and how long that is allowed to take is not this binary's decision.
686
- func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
687
- ttsx := os.Getenv("TTSC_TTSX_BINARY")
688
- if ttsx == "" {
689
- ttsx = "ttsx"
690
- }
927
+ //
928
+ // `anchors` are the file paths the launcher is resolved from; see
929
+ // resolveTtsxLauncher.
930
+ func ttsxCommandContext(ctx context.Context, anchors []string, args ...string) *exec.Cmd {
931
+ ttsx := resolveTtsxLauncher(anchors)
691
932
  if shouldRunTtsxThroughNode(ttsx) {
692
933
  node := os.Getenv("TTSC_NODE_BINARY")
693
934
  if node == "" {
@@ -771,6 +1012,20 @@ func findNearestNodeModules(start string) string {
771
1012
  }
772
1013
  }
773
1014
 
1015
+ // relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
1016
+ // import specifier for location relative to fromDir.
1017
+ func relativeImportSpecifier(fromDir, location string) (string, error) {
1018
+ relative, err := filepath.Rel(fromDir, location)
1019
+ if err != nil {
1020
+ return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
1021
+ }
1022
+ relative = filepath.ToSlash(relative)
1023
+ if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
1024
+ return relative, nil
1025
+ }
1026
+ return "./" + relative, nil
1027
+ }
1028
+
774
1029
  // setEnv returns a copy of env with key=value. If key already exists in env,
775
1030
  // its value is updated in-place; otherwise the entry is appended.
776
1031
  func setEnv(env []string, key, value string) []string {
@@ -784,12 +1039,6 @@ func setEnv(env []string, key, value string) []string {
784
1039
  return append(env, prefix+value)
785
1040
  }
786
1041
 
787
- // sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
788
- // by replacing "*/" with "* /" so the generated block comment stays valid.
789
- func sanitizeJSDocLine(line string) string {
790
- return strings.ReplaceAll(line, "*/", "* /")
791
- }
792
-
793
1042
  // loaderFailureReason reads the failure envelope a config loader writes to its
794
1043
  // payload channel when it stops on an error it can name.
795
1044
  //
@@ -809,3 +1058,5 @@ func loaderFailureReason(output []byte) string {
809
1058
  }
810
1059
  return strings.TrimSpace(envelope.Message)
811
1060
  }
1061
+
1062
+ // ttsc:config-loader-shared end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "First-party ttsc plugin that adds package-documentation JSDoc banners during emit.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -35,7 +35,7 @@
35
35
  "@types/node": "^25.3.0",
36
36
  "rimraf": "^6.1.2",
37
37
  "typescript": "^7.0.2",
38
- "ttsc": "0.26.0"
38
+ "ttsc": "0.26.2"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",