@ttsc/banner 0.26.1 → 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/banner.go CHANGED
@@ -3,6 +3,7 @@ package banner
3
3
  import (
4
4
  "bytes"
5
5
  "context"
6
+ "crypto/sha256"
6
7
  "encoding/json"
7
8
  "fmt"
8
9
  "os"
@@ -61,13 +62,21 @@ func validateBannerConfig(config map[string]any) error {
61
62
  // SourcePreamble resolves the banner text from the plugin config and returns it
62
63
  // formatted as a JSDoc block comment suitable for prepending to each emitted file.
63
64
  func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
64
- return parseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
65
+ return parseBannerWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
65
66
  }
66
67
 
67
68
  // parseBanner resolves and formats banner text into a JSDoc block comment.
68
69
  // Trailing blank lines are stripped from the resolved text before formatting.
69
70
  func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error) {
70
- text, err := resolveBannerText(config, cwd, tsconfigPath)
71
+ return parseBannerWithReporter(config, cwd, tsconfigPath, nil)
72
+ }
73
+
74
+ func parseBannerWithReporter(config map[string]any, cwd, tsconfigPath string, reporter func(string)) (string, error) {
75
+ return parseBannerWithReporters(config, cwd, tsconfigPath, reporter, nil, nil)
76
+ }
77
+
78
+ func parseBannerWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string)) (string, error) {
79
+ text, err := resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, hashReporter, realpathReporter)
71
80
  if err != nil {
72
81
  return "", err
73
82
  }
@@ -92,16 +101,34 @@ func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error
92
101
  return b.String(), nil
93
102
  }
94
103
 
104
+ // sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
105
+ // by replacing "*/" with "* /" so the generated block comment stays valid.
106
+ func sanitizeJSDocLine(line string) string {
107
+ return strings.ReplaceAll(line, "*/", "* /")
108
+ }
109
+
95
110
  // resolveBannerText extracts the banner text from the plugin config.
96
111
  // The config entry is validated first: only the "configFile" key (plus
97
112
  // framework keys) is accepted. When "configFile" is present its value is
98
113
  // resolved to an absolute path and loaded. When absent the upward-walk
99
114
  // discovery is used. Returns an error when the config is invalid or when
100
115
  // no banner text can be found.
116
+ //
117
+ // The discovery base directory doubles as the resolution root the config
118
+ // loader anchors its toolchain lookup on; see configToolAnchors.
101
119
  func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string, error) {
120
+ return resolveBannerTextWithReporter(config, cwd, tsconfigPath, nil)
121
+ }
122
+
123
+ func resolveBannerTextWithReporter(config map[string]any, cwd, tsconfigPath string, reporter func(string)) (string, error) {
124
+ return resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, nil, nil)
125
+ }
126
+
127
+ func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string)) (string, error) {
102
128
  if err := validateBannerConfig(config); err != nil {
103
129
  return "", err
104
130
  }
131
+ resolutionRoot := tsconfigBaseDir(cwd, tsconfigPath)
105
132
 
106
133
  if rawConfigFile, ok := config["configFile"]; ok {
107
134
  configFile, ok := rawConfigFile.(string)
@@ -109,11 +136,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
109
136
  return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
110
137
  }
111
138
  location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
112
- raw, err := loadBannerConfigFile(location)
139
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
113
140
  if err != nil {
114
141
  return "", err
115
142
  }
116
- text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
143
+ reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
144
+ text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
117
145
  if err != nil {
118
146
  return "", err
119
147
  }
@@ -130,11 +158,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
130
158
  if location == "" {
131
159
  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
160
  }
133
- raw, err := loadBannerConfigFile(location)
161
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
134
162
  if err != nil {
135
163
  return "", err
136
164
  }
137
- text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
165
+ reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
166
+ text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
138
167
  if err != nil {
139
168
  return "", err
140
169
  }
@@ -238,18 +267,123 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
238
267
  // must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}; JS/CJS/MJS variants
239
268
  // run under Node, TypeScript variants compile and run via ttsx in a temp
240
269
  // directory, and JSON files are parsed natively.
241
- func loadBannerConfigFile(location string) (any, error) {
270
+ //
271
+ // resolutionRoot is the project directory the TypeScript branch anchors its
272
+ // toolchain resolution on when the config file's own ancestry answers nothing;
273
+ // see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
274
+ func loadBannerConfigFile(location, resolutionRoot string) (any, error) {
275
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
276
+ return loaded.value, err
277
+ }
278
+
279
+ type bannerLoadedConfig struct {
280
+ hashes map[string]*string
281
+ inputs []string
282
+ realpaths map[string]*string
283
+ value any
284
+ }
285
+
286
+ func loadBannerConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
242
287
  if !isBannerConfigFileName(filepath.Base(location)) {
243
- return nil, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
288
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
244
289
  }
245
290
  ext := strings.ToLower(filepath.Ext(location))
246
291
  switch ext {
247
292
  case ".json":
248
- return loadBannerJSONConfigFile(location)
293
+ body, err := os.ReadFile(location)
294
+ if err != nil {
295
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
296
+ }
297
+ value, err := parseBannerJSONConfigFile(location, body)
298
+ digest := fmt.Sprintf("%x", sha256.Sum256(body))
299
+ return bannerLoadedConfig{hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: physicalHostInput(location)}, value: value}, err
249
300
  case ".js", ".cjs", ".mjs":
250
- return loadBannerScriptConfigFile(location)
301
+ return loadBannerScriptConfigFileWithInputs(location)
302
+ }
303
+ return loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
304
+ }
305
+
306
+ func reportBannerConfigInputs(inputs []string, hashes, realpaths map[string]*string, reporter func(string), hashReporter, realpathReporter func(string, *string)) {
307
+ if reporter == nil && hashReporter == nil && realpathReporter == nil {
308
+ return
309
+ }
310
+ for _, input := range inputs {
311
+ if reporter != nil {
312
+ reporter(input)
313
+ }
314
+ if hashReporter != nil {
315
+ if hash, ok := hashes[input]; ok {
316
+ hashReporter(input, hash)
317
+ }
318
+ }
319
+ if realpathReporter != nil {
320
+ if realpath, ok := realpaths[input]; ok {
321
+ realpathReporter(input, realpath)
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ func physicalHostInput(file string) *string {
328
+ resolved, err := filepath.Abs(file)
329
+ if err != nil {
330
+ return nil
331
+ }
332
+ resolved = filepath.Clean(resolved)
333
+ seen := make(map[string]struct{})
334
+ for range 255 {
335
+ if _, exists := seen[resolved]; exists {
336
+ return nil
337
+ }
338
+ seen[resolved] = struct{}{}
339
+ if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil {
340
+ evaluated, evalErr = filepath.Abs(evaluated)
341
+ if evalErr != nil {
342
+ return nil
343
+ }
344
+ evaluated = filepath.Clean(evaluated)
345
+ if _, statErr := os.Stat(evaluated); statErr != nil {
346
+ return nil
347
+ }
348
+ return &evaluated
349
+ }
350
+ next, ok := resolveHostInputLinkAncestor(resolved)
351
+ if !ok {
352
+ return nil
353
+ }
354
+ resolved = next
355
+ }
356
+ return nil
357
+ }
358
+
359
+ // resolveHostInputLinkAncestor follows the nearest link-like ancestor and
360
+ // reattaches its remaining suffix. Windows junction children can be opened and
361
+ // os.Readlink exposes the junction itself even when EvalSymlinks rejects the
362
+ // complete child path.
363
+ func resolveHostInputLinkAncestor(location string) (string, bool) {
364
+ probe := filepath.Clean(location)
365
+ suffix := make([]string, 0)
366
+ for {
367
+ if target, err := os.Readlink(probe); err == nil {
368
+ if !filepath.IsAbs(target) {
369
+ target = filepath.Join(filepath.Dir(probe), target)
370
+ }
371
+ for i := len(suffix) - 1; i >= 0; i-- {
372
+ target = filepath.Join(target, suffix[i])
373
+ }
374
+ absolute, absErr := filepath.Abs(target)
375
+ if absErr != nil {
376
+ return "", false
377
+ }
378
+ return filepath.Clean(absolute), true
379
+ }
380
+ parent := filepath.Dir(probe)
381
+ if parent == probe {
382
+ return "", false
383
+ }
384
+ suffix = append(suffix, filepath.Base(probe))
385
+ probe = parent
251
386
  }
252
- return loadBannerTypeScriptConfigFile(location)
253
387
  }
254
388
 
255
389
  // isBannerConfigFileName reports whether name is an allowed banner config file name.
@@ -276,6 +410,10 @@ func loadBannerJSONConfigFile(location string) (any, error) {
276
410
  if err != nil {
277
411
  return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
278
412
  }
413
+ return parseBannerJSONConfigFile(location, body)
414
+ }
415
+
416
+ func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
279
417
  // Strip a leading UTF-8 BOM so files saved by Windows editors round
280
418
  // trip through json.Unmarshal without an opaque "invalid character" failure.
281
419
  body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
@@ -290,8 +428,207 @@ func loadBannerJSONConfigFile(location string) (any, error) {
290
428
  // running a small Node.js loader script that dynamic-imports the file and
291
429
  // serializes its exported value to stdout as JSON.
292
430
  func loadBannerScriptConfigFile(location string) (any, error) {
431
+ loaded, err := loadBannerScriptConfigFileWithInputs(location)
432
+ return loaded.value, err
433
+ }
434
+
435
+ func loadBannerScriptConfigFileWithInputs(location string) (bannerLoadedConfig, error) {
293
436
  const script = `
294
- const { pathToFileURL } = require("node:url");
437
+ const { createRequire, isBuiltin, registerHooks } = require("node:module");
438
+ const crypto = require("node:crypto");
439
+ const fs = require("node:fs");
440
+ const path = require("node:path");
441
+ const { fileURLToPath, pathToFileURL } = require("node:url");
442
+ const inputs = new Set();
443
+ const hashes = new Map();
444
+ const realpaths = new Map();
445
+ const signatures = new Map();
446
+ const unstableHashes = new Set();
447
+
448
+ function existingFile(file) {
449
+ try { return fs.statSync(file).isFile(); }
450
+ catch { return false; }
451
+ }
452
+
453
+ function missingPathError(error) {
454
+ return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
455
+ }
456
+
457
+ function inputMetadataSignature(file) {
458
+ const requested = path.resolve(file);
459
+ let current = requested;
460
+ for (;;) {
461
+ try {
462
+ const link = fs.lstatSync(current, { bigint: true });
463
+ let target = link;
464
+ if (link.isSymbolicLink()) {
465
+ try { target = fs.statSync(current, { bigint: true }); }
466
+ catch { return undefined; }
467
+ }
468
+ 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(":");
469
+ } catch (error) {
470
+ if (!missingPathError(error)) return undefined;
471
+ const parent = path.dirname(current);
472
+ if (parent === current) return undefined;
473
+ current = parent;
474
+ }
475
+ }
476
+ }
477
+
478
+ function recordInput(file) {
479
+ file = path.resolve(file);
480
+ inputs.add(file);
481
+ if (unstableHashes.has(file)) return;
482
+ const beforeSignature = inputMetadataSignature(file);
483
+ let observed;
484
+ let observedRealpath;
485
+ 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"); }
486
+ catch { observed = null; }
487
+ try { observedRealpath = fs.realpathSync.native(file); }
488
+ catch { observedRealpath = null; }
489
+ const afterSignature = inputMetadataSignature(file);
490
+ 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)) {
491
+ hashes.delete(file);
492
+ realpaths.delete(file);
493
+ signatures.delete(file);
494
+ unstableHashes.add(file);
495
+ return;
496
+ }
497
+ signatures.set(file, afterSignature);
498
+ hashes.set(file, observed);
499
+ realpaths.set(file, observedRealpath);
500
+ }
501
+
502
+ function recordFile(file) {
503
+ const resolvedFile = path.resolve(file);
504
+ recordInput(resolvedFile);
505
+ for (let directory = path.dirname(resolvedFile);;) {
506
+ const manifest = path.join(directory, "package.json");
507
+ recordInput(manifest);
508
+ if (existingFile(manifest)) {
509
+ break;
510
+ }
511
+ const parent = path.dirname(directory);
512
+ if (parent === directory) {
513
+ break;
514
+ }
515
+ directory = parent;
516
+ }
517
+ }
518
+
519
+ function recordPackageManifests(file) {
520
+ for (let directory = path.dirname(path.resolve(file));;) {
521
+ const manifest = path.join(directory, "package.json");
522
+ recordInput(manifest);
523
+ if (existingFile(manifest)) return;
524
+ const parent = path.dirname(directory);
525
+ if (parent === directory) return;
526
+ directory = parent;
527
+ }
528
+ }
529
+
530
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];
531
+ function moduleCandidates(base) {
532
+ return [
533
+ base,
534
+ ...moduleProbeExtensions.map((extension) => base + extension),
535
+ path.join(base, "package.json"),
536
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
537
+ ];
538
+ }
539
+ const recordedModuleBases = new Set();
540
+ function recordManifestTargets(value, directory, allowBare = false) {
541
+ if (typeof value === "string") {
542
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
543
+ return;
544
+ }
545
+ if (Array.isArray(value)) {
546
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
547
+ return;
548
+ }
549
+ if (value && typeof value === "object") {
550
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
551
+ }
552
+ }
553
+ function recordModuleCandidates(base) {
554
+ const resolvedBase = path.resolve(base);
555
+ if (recordedModuleBases.has(resolvedBase)) return;
556
+ recordedModuleBases.add(resolvedBase);
557
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
558
+ try {
559
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
560
+ recordManifestTargets(manifest.exports, resolvedBase);
561
+ recordManifestTargets(manifest.module, resolvedBase, true);
562
+ recordManifestTargets(manifest.main, resolvedBase, true);
563
+ } catch {}
564
+ }
565
+ function candidateSelected(base, resolvedFile) {
566
+ for (const candidate of moduleCandidates(base)) {
567
+ try {
568
+ const canonical = fs.realpathSync.native(candidate);
569
+ const relative = path.relative(canonical, resolvedFile);
570
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
571
+ } catch {}
572
+ }
573
+ return false;
574
+ }
575
+ function localBases(specifier, parentDirectory) {
576
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
577
+ const raw = path.resolve(parentDirectory, specifier);
578
+ const suffixStart = specifier.search(/[?#]/);
579
+ if (suffixStart === -1) return [raw];
580
+ const pathname = specifier.slice(0, suffixStart);
581
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
582
+ }
583
+ function recordResolutionCandidates(specifier, parentURL, resolvedURL) {
584
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
585
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
586
+ let resolvedFile;
587
+ try {
588
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
589
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
590
+ : undefined;
591
+ } catch {}
592
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
593
+ try {
594
+ for (const base of localBases(specifier, parentDirectory)) {
595
+ recordPackageManifests(base);
596
+ let exact = false;
597
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
598
+ if (exact) recordInput(base);
599
+ else recordModuleCandidates(base);
600
+ }
601
+ } catch {}
602
+ return;
603
+ }
604
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
605
+ const parts = specifier.split("/");
606
+ const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
607
+ if (packageParts.some((part) => part === undefined || part === "")) return;
608
+ const packageName = packageParts.join("/");
609
+ const subpath = parts.slice(packageParts.length);
610
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
611
+ for (const searchPath of searchPaths) {
612
+ const packageDirectory = path.join(searchPath, packageName);
613
+ recordModuleCandidates(packageDirectory);
614
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
615
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
616
+ }
617
+ }
618
+
619
+ recordFile(process.argv[1]);
620
+ registerHooks({
621
+ resolve(specifier, context, nextResolve) {
622
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
623
+ const resolved = nextResolve(specifier, context);
624
+ const url = typeof resolved === "string" ? resolved : resolved && resolved.url;
625
+ recordResolutionCandidates(specifier, context.parentURL, url);
626
+ if (typeof url === "string" && url.startsWith("file:")) {
627
+ recordFile(fileURLToPath(url));
628
+ }
629
+ return resolved;
630
+ },
631
+ });
295
632
 
296
633
  (async () => {
297
634
  const mod = await import(pathToFileURL(process.argv[1]).href);
@@ -307,15 +644,11 @@ const { pathToFileURL } = require("node:url");
307
644
  break;
308
645
  }
309
646
  const value = typeof current === "function" ? await current() : current;
310
- process.stdout.write(JSON.stringify(toSerializableBanner(value)));
647
+ const serializedValue = toSerializableBanner(value);
648
+ for (const input of [...inputs]) recordInput(input);
649
+ process.stdout.write(JSON.stringify({ value: serializedValue, hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
311
650
  })().catch((error) => {
312
651
  process.stderr.write(error && error.stack ? error.stack : String(error));
313
- // The stack above is for the reader. This is for the caller: the parent reads
314
- // stdout as the payload channel either way, so a failure reason travels as
315
- // data rather than as text scraped back out of a captured stream. The exit
316
- // code is set before the write so a callback that never fires still fails the
317
- // load, and the write's completion is what triggers the exit, because
318
- // process.exit abandons a pending pipe write.
319
652
  process.exitCode = 1;
320
653
  process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
321
654
  });
@@ -333,7 +666,13 @@ function toSerializableBanner(value) {
333
666
  }
334
667
  ctx, cancel := context.WithCancel(context.Background())
335
668
  defer cancel()
336
- cmd := exec.CommandContext(ctx, node, "-e", script, location)
669
+ // Windows limits the whole process command line to roughly 32 KiB. The
670
+ // dependency-tracking loader is intentionally larger than that, so keep only
671
+ // an explicit CommonJS stdin program and remove Node's stdin sentinel before
672
+ // the loader runs. This preserves the historical process.argv layout seen by
673
+ // both the loader and the imported user config without using string eval.
674
+ cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
675
+ cmd.Stdin = strings.NewReader("process.argv.splice(1, 1);\n" + script)
337
676
  cmd.Env = nodeConfigLoaderEnv(location)
338
677
  // The child's stderr is human output and goes straight to this process's
339
678
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -346,15 +685,46 @@ function toSerializableBanner(value) {
346
685
  // What it could not put there is a reason a caller can act on, so that
347
686
  // arrives through the payload channel instead.
348
687
  if reason := loaderFailureReason(output); reason != "" {
349
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
688
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
350
689
  }
351
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
690
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
352
691
  }
353
- var out any
354
- if err := json.Unmarshal(output, &out); err != nil {
355
- return nil, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
692
+ loaded, err := decodeBannerConfigLoaderOutput(output)
693
+ if err != nil {
694
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
356
695
  }
357
- return out, nil
696
+ return loaded, nil
697
+ }
698
+
699
+ func decodeBannerConfigLoaderOutput(output []byte) (bannerLoadedConfig, error) {
700
+ var envelope struct {
701
+ Error string `json:"__ttscLoaderError"`
702
+ Hashes map[string]*string `json:"hashes"`
703
+ Inputs []string `json:"inputs"`
704
+ Realpaths map[string]*string `json:"realpaths"`
705
+ Value json.RawMessage `json:"value"`
706
+ }
707
+ if err := json.Unmarshal(output, &envelope); err != nil {
708
+ return bannerLoadedConfig{}, err
709
+ }
710
+ if envelope.Error != "" {
711
+ return bannerLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
712
+ }
713
+ if len(envelope.Value) == 0 {
714
+ // Test/fallback launchers written against the historical payload return
715
+ // the config value directly. Preserve that accepted contract while real
716
+ // loaders use the envelope to carry runtime inputs.
717
+ var value any
718
+ if err := json.Unmarshal(output, &value); err != nil {
719
+ return bannerLoadedConfig{}, err
720
+ }
721
+ return bannerLoadedConfig{value: value}, nil
722
+ }
723
+ var value any
724
+ if err := json.Unmarshal(envelope.Value, &value); err != nil {
725
+ return bannerLoadedConfig{}, err
726
+ }
727
+ return bannerLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
358
728
  }
359
729
 
360
730
  // loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
@@ -362,29 +732,38 @@ function toSerializableBanner(value) {
362
732
  // is created so the config file can import its own dependencies. The ttsx
363
733
  // build runs with `--no-plugins` so evaluating the config never triggers the
364
734
  // host project's transform/check plugins against the loader tsconfig.
365
- func loadBannerTypeScriptConfigFile(location string) (any, error) {
735
+ //
736
+ // Both tools this spawns — the launcher and the compiler handed to it — are
737
+ // resolved from the project rather than from the process environment alone;
738
+ // see configToolAnchors.
739
+ func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
740
+ loaded, err := loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
741
+ return loaded.value, err
742
+ }
743
+
744
+ func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
366
745
  tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
367
746
  if err != nil {
368
- return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
747
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
369
748
  }
370
749
  defer os.RemoveAll(tempDir)
371
750
 
372
751
  if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
373
- return nil, err
752
+ return bannerLoadedConfig{}, err
374
753
  }
375
754
 
376
755
  loader := filepath.Join(tempDir, "loader.mts")
377
756
  tsconfig := filepath.Join(tempDir, "tsconfig.json")
378
757
  importSpecifier, err := relativeImportSpecifier(tempDir, location)
379
758
  if err != nil {
380
- return nil, err
759
+ return bannerLoadedConfig{}, err
381
760
  }
382
761
  importLiteral, _ := json.Marshal(importSpecifier)
383
762
  if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
384
- return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
763
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
385
764
  }
386
765
  if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
387
- return nil, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
766
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
388
767
  }
389
768
 
390
769
  args := []string{
@@ -393,14 +772,15 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
393
772
  "--cache-dir", filepath.Join(tempDir, "cache"),
394
773
  "--no-plugins",
395
774
  }
396
- if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
775
+ anchors := configToolAnchors(location, resolutionRoot)
776
+ if tsgo := resolveConfigTsgo(anchors); tsgo != "" {
397
777
  args = append(args, "--binary", tsgo)
398
778
  }
399
779
  args = append(args, loader)
400
780
 
401
781
  ctx, cancel := context.WithCancel(context.Background())
402
782
  defer cancel()
403
- cmd := ttsxCommandContext(ctx, args...)
783
+ cmd := ttsxCommandContext(ctx, anchors, args...)
404
784
  cmd.Env = nodeConfigLoaderEnv(location)
405
785
  // The child's stderr is human output and goes straight to this process's
406
786
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -413,36 +793,232 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
413
793
  // What it could not put there is a reason a caller can act on, so that
414
794
  // arrives through the payload channel instead.
415
795
  if reason := loaderFailureReason(output); reason != "" {
416
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
796
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
417
797
  }
418
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
798
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
419
799
  }
420
- var out any
421
- if err := json.Unmarshal(output, &out); err != nil {
422
- return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
423
- }
424
- return out, nil
425
- }
426
-
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)
800
+ loaded, err := decodeBannerConfigLoaderOutput(output)
431
801
  if err != nil {
432
- return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
802
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
433
803
  }
434
- relative = filepath.ToSlash(relative)
435
- if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
436
- return relative, nil
437
- }
438
- return "./" + relative, nil
804
+ return loaded, nil
439
805
  }
440
806
 
441
807
  // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
442
808
  // module that imports the banner config file specified by importLiteral (a
443
809
  // JSON-encoded import specifier) and writes the serialized banner value to stdout.
444
810
  func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
445
- return fmt.Sprintf(`import * as importedConfig from %s;
811
+ return fmt.Sprintf(`// @ts-nocheck
812
+ import { createRequire, isBuiltin, registerHooks } from "node:module";
813
+ import crypto from "node:crypto";
814
+ import fs from "node:fs";
815
+ import path from "node:path";
816
+ import { fileURLToPath } from "node:url";
817
+
818
+ const inputs = new Set<string>();
819
+ const hashes = new Map<string, string | null>();
820
+ const realpaths = new Map<string, string | null>();
821
+ const signatures = new Map<string, string>();
822
+ const unstableHashes = new Set<string>();
823
+
824
+ function existingFile(file: string): boolean {
825
+ try { return fs.statSync(file).isFile(); }
826
+ catch { return false; }
827
+ }
828
+
829
+ function missingPathError(error: unknown): boolean {
830
+ const code = (error as { code?: unknown } | undefined)?.code;
831
+ return code === "ENOENT" || code === "ENOTDIR";
832
+ }
833
+
834
+ function inputMetadataSignature(file: string): string | undefined {
835
+ const requested = path.resolve(file);
836
+ let current = requested;
837
+ for (;;) {
838
+ try {
839
+ const link = fs.lstatSync(current, { bigint: true });
840
+ let target = link;
841
+ if (link.isSymbolicLink()) {
842
+ try { target = fs.statSync(current, { bigint: true }); }
843
+ catch { return undefined; }
844
+ }
845
+ 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(":");
846
+ } catch (error) {
847
+ if (!missingPathError(error)) return undefined;
848
+ const parent = path.dirname(current);
849
+ if (parent === current) return undefined;
850
+ current = parent;
851
+ }
852
+ }
853
+ }
854
+
855
+ function recordInput(file: string): void {
856
+ file = path.resolve(file);
857
+ inputs.add(file);
858
+ if (unstableHashes.has(file)) return;
859
+ const beforeSignature = inputMetadataSignature(file);
860
+ let observed: string | null;
861
+ let observedRealpath: string | null;
862
+ 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"); }
863
+ catch { observed = null; }
864
+ try { observedRealpath = fs.realpathSync.native(file); }
865
+ catch { observedRealpath = null; }
866
+ const afterSignature = inputMetadataSignature(file);
867
+ 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)) {
868
+ hashes.delete(file);
869
+ realpaths.delete(file);
870
+ signatures.delete(file);
871
+ unstableHashes.add(file);
872
+ return;
873
+ }
874
+ signatures.set(file, afterSignature);
875
+ hashes.set(file, observed);
876
+ realpaths.set(file, observedRealpath);
877
+ }
878
+
879
+ function recordFile(file: string): void {
880
+ const resolvedFile = path.resolve(file);
881
+ recordInput(resolvedFile);
882
+ for (let directory = path.dirname(resolvedFile);;) {
883
+ const manifest = path.join(directory, "package.json");
884
+ recordInput(manifest);
885
+ if (existingFile(manifest)) {
886
+ break;
887
+ }
888
+ const parent = path.dirname(directory);
889
+ if (parent === directory) {
890
+ break;
891
+ }
892
+ directory = parent;
893
+ }
894
+ }
895
+
896
+ function recordPackageManifests(file: string): void {
897
+ for (let directory = path.dirname(path.resolve(file));;) {
898
+ const manifest = path.join(directory, "package.json");
899
+ recordInput(manifest);
900
+ if (existingFile(manifest)) return;
901
+ const parent = path.dirname(directory);
902
+ if (parent === directory) return;
903
+ directory = parent;
904
+ }
905
+ }
906
+
907
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] as const;
908
+ const jsToTsProbeExtensions = new Map<string, readonly string[]>([
909
+ [".js", [".ts", ".tsx"]],
910
+ [".jsx", [".tsx"]],
911
+ [".mjs", [".mts"]],
912
+ [".cjs", [".cts"]],
913
+ ]);
914
+ function sourceSubstitutionCandidates(base: string): string[] {
915
+ const extension = path.extname(base).toLowerCase();
916
+ const substitutions = jsToTsProbeExtensions.get(extension);
917
+ if (substitutions === undefined) return [];
918
+ const stem = base.slice(0, base.length - extension.length);
919
+ return substitutions.map((candidate) => stem + candidate);
920
+ }
921
+ function moduleCandidates(base: string): string[] {
922
+ return [
923
+ base,
924
+ ...sourceSubstitutionCandidates(base),
925
+ ...moduleProbeExtensions.map((extension) => base + extension),
926
+ path.join(base, "package.json"),
927
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
928
+ ];
929
+ }
930
+ const recordedModuleBases = new Set<string>();
931
+ function recordManifestTargets(value: unknown, directory: string, allowBare: boolean = false): void {
932
+ if (typeof value === "string") {
933
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
934
+ return;
935
+ }
936
+ if (Array.isArray(value)) {
937
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
938
+ return;
939
+ }
940
+ if (value !== null && typeof value === "object") {
941
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
942
+ }
943
+ }
944
+ function recordModuleCandidates(base: string): void {
945
+ const resolvedBase = path.resolve(base);
946
+ if (recordedModuleBases.has(resolvedBase)) return;
947
+ recordedModuleBases.add(resolvedBase);
948
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
949
+ try {
950
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
951
+ recordManifestTargets(manifest.exports, resolvedBase);
952
+ recordManifestTargets(manifest.module, resolvedBase, true);
953
+ recordManifestTargets(manifest.main, resolvedBase, true);
954
+ } catch {}
955
+ }
956
+ function candidateSelected(base: string, resolvedFile: string): boolean {
957
+ for (const candidate of moduleCandidates(base)) {
958
+ try {
959
+ const canonical = fs.realpathSync.native(candidate);
960
+ const relative = path.relative(canonical, resolvedFile);
961
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
962
+ } catch {}
963
+ }
964
+ return false;
965
+ }
966
+ function localBases(specifier: string, parentDirectory: string): string[] {
967
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
968
+ const raw = path.resolve(parentDirectory, specifier);
969
+ const suffixStart = specifier.search(/[?#]/);
970
+ if (suffixStart === -1) return [raw];
971
+ const pathname = specifier.slice(0, suffixStart);
972
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
973
+ }
974
+ function recordResolutionCandidates(specifier: string, parentURL: string | undefined, resolvedURL: string | undefined): void {
975
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
976
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
977
+ let resolvedFile: string | undefined;
978
+ try {
979
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
980
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
981
+ : undefined;
982
+ } catch {}
983
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
984
+ try {
985
+ for (const base of localBases(specifier, parentDirectory)) {
986
+ recordPackageManifests(base);
987
+ let exact = false;
988
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
989
+ if (exact) recordInput(base);
990
+ else recordModuleCandidates(base);
991
+ }
992
+ } catch {}
993
+ return;
994
+ }
995
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
996
+ const parts = specifier.split("/");
997
+ const packageParts = parts[0]!.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
998
+ if (packageParts.some((part) => part === undefined || part === "")) return;
999
+ const packageName = packageParts.join("/");
1000
+ const subpath = parts.slice(packageParts.length);
1001
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
1002
+ for (const searchPath of searchPaths) {
1003
+ const packageDirectory = path.join(searchPath, packageName);
1004
+ recordModuleCandidates(packageDirectory);
1005
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
1006
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
1007
+ }
1008
+ }
1009
+
1010
+ registerHooks({
1011
+ resolve(specifier, context, nextResolve) {
1012
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
1013
+ const resolved = nextResolve(specifier, context);
1014
+ const url = typeof resolved === "string" ? resolved : resolved?.url;
1015
+ recordResolutionCandidates(specifier, context.parentURL, url);
1016
+ if (typeof url === "string" && url.startsWith("file:")) {
1017
+ recordFile(fileURLToPath(url));
1018
+ }
1019
+ return resolved;
1020
+ },
1021
+ });
446
1022
 
447
1023
  declare const process: {
448
1024
  exitCode?: number;
@@ -458,8 +1034,16 @@ declare const process: {
458
1034
  // left for a trailing handler to settle.
459
1035
  (async () => {
460
1036
  try {
1037
+ const importedConfig = await import(%s);
461
1038
  const value = await resolveConfig(importedConfig);
462
- process.stdout.write(JSON.stringify(toSerializableBanner(value)));
1039
+ const serializedValue = toSerializableBanner(value);
1040
+ for (const input of [...inputs]) recordInput(input);
1041
+ process.stdout.write(JSON.stringify({
1042
+ value: serializedValue,
1043
+ hashes: Object.fromEntries(hashes),
1044
+ inputs: [...inputs].sort(),
1045
+ realpaths: Object.fromEntries(realpaths),
1046
+ }));
463
1047
  } catch (error) {
464
1048
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
465
1049
  // The stack above is for the reader. This is for the caller: the parent
@@ -529,6 +1113,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
529
1113
  // resolving either way.
530
1114
  "module": configModuleOption(location),
531
1115
  "moduleResolution": "bundler",
1116
+ "jsx": "preserve",
532
1117
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
533
1118
  "rewriteRelativeImportExtensions": true,
534
1119
  "rootDir": loaderRootDir(outDir),
@@ -551,6 +1136,23 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
551
1136
  return string(body)
552
1137
  }
553
1138
 
1139
+ // ttsc:config-loader-shared begin
1140
+ //
1141
+ // One policy in three Go copies: everything between these markers is
1142
+ // duplicated verbatim in packages/lint/linthost/config.go,
1143
+ // packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
1144
+ // decided against extracting it — the only home the three modules could share
1145
+ // is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
1146
+ // deliberately requires no in-tree ttsc module — and replaced the checklist
1147
+ // with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
1148
+ // between these markers across all three copies on every pull request, so
1149
+ // editing one and not the others fails by name. That file's header carries the
1150
+ // full decision and the rules for changing this block.
1151
+ //
1152
+ // The code between the markers must stay identical. Comments may differ, the
1153
+ // `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
1154
+ // a `strip` prefix. Anything package-specific belongs outside the markers.
1155
+
554
1156
  // configModuleOption returns the loader tsconfig's "module" for a config file:
555
1157
  // the module kind Node itself would give that file.
556
1158
  //
@@ -673,21 +1275,240 @@ func resolveDirLink(dir string) string {
673
1275
  return dir
674
1276
  }
675
1277
 
1278
+ // realpathIfPossible resolves location through its symlinks, and returns it
1279
+ // unchanged when it cannot be evaluated (a path that does not exist, or an
1280
+ // NTFS junction filepath.EvalSymlinks refuses to traverse).
1281
+ func realpathIfPossible(location string) string {
1282
+ real, err := filepath.EvalSymlinks(location)
1283
+ if err != nil {
1284
+ return location
1285
+ }
1286
+ return real
1287
+ }
1288
+
1289
+ // Both tools the TypeScript config evaluator needs — the `ttsx` launcher it
1290
+ // spawns and the native compiler it hands that launcher — are resolved from
1291
+ // the project being compiled, with an explicit environment variable winning
1292
+ // and a last resort that invents no path.
1293
+ //
1294
+ // The three Go copies are held identical by the gate named at the top of this
1295
+ // block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
1296
+ // packages/lint/src/index.ts — is a fourth copy in another language that no Go
1297
+ // gate can reach; what it owes is that both policies stay describable in one
1298
+ // sentence.
1299
+ //
1300
+ // The environment alone is the wrong place to ask. `ttsx` exports
1301
+ // TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
1302
+ // launched under `ttsx` inherited both and a host launched any other way
1303
+ // inherited neither. The shipped `ttscserver` binary invoked with its
1304
+ // documented `--tsgo <path>` flag keeps that path in a local and exports
1305
+ // nothing, and an embedder of the driver package exports nothing either. For
1306
+ // those the evaluator spawned a bare `ttsx` that only a global install puts on
1307
+ // PATH, and, past that, a compiler-less child that aborted with
1308
+ // `ttsc: typescript is required` before a line of the config was read.
1309
+ //
1310
+ // configToolAnchors lists the file paths those resolutions walk upward from,
1311
+ // in order: the config file being evaluated, then the resolution root's
1312
+ // manifest. The config comes first because it is the file whose own
1313
+ // installation decides which toolchain the config's imports were written
1314
+ // against; the resolution root answers for a config that lives outside the
1315
+ // project tree (a `configFile` pointed at a shared package), and for one
1316
+ // discovered above a workspace that installs its own toolchain.
1317
+ func configToolAnchors(configPath, resolutionRoot string) []string {
1318
+ anchors := make([]string, 0, 2)
1319
+ if strings.TrimSpace(configPath) != "" {
1320
+ anchors = append(anchors, configPath)
1321
+ }
1322
+ if strings.TrimSpace(resolutionRoot) != "" {
1323
+ anchors = append(anchors, filepath.Join(resolutionRoot, "package.json"))
1324
+ }
1325
+ return anchors
1326
+ }
1327
+
1328
+ // resolveConfigTsgo returns the native TypeScript compiler the evaluator hands
1329
+ // its ttsx child through `--binary`, or "" to leave the child resolving for
1330
+ // itself.
1331
+ //
1332
+ // The child runs with `--cwd <ephemeral loader dir>`, so it cannot discover
1333
+ // `typescript` the way an ordinary invocation does: linkNearestNodeModules is
1334
+ // the only thing that puts the project's modules within its reach, and it links
1335
+ // nothing when the config's ancestry carries no node_modules. An explicit
1336
+ // TTSC_TSGO_BINARY still wins, so an embedder that pins a compiler keeps
1337
+ // pinning it. "" is the unchanged last resort: a project that cannot answer
1338
+ // here could not answer inside the child either, and the child's own diagnostic
1339
+ // is the one that names the missing package.
1340
+ func resolveConfigTsgo(anchors []string) string {
1341
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TSGO_BINARY")); explicit != "" {
1342
+ return explicit
1343
+ }
1344
+ for _, anchor := range anchors {
1345
+ if binary := tsgoBinaryFrom(anchor); binary != "" {
1346
+ return binary
1347
+ }
1348
+ }
1349
+ return ""
1350
+ }
1351
+
1352
+ // tsgoBinaryFrom returns the platform compiler executable of the `typescript`
1353
+ // install `anchor` can see, or "" when this anchor reaches neither the package
1354
+ // nor its platform dependency.
1355
+ //
1356
+ // Mirrors resolveTsgo.ts so the Go plugin and the JS launcher name one file:
1357
+ // the `typescript` manifest, then `@typescript/typescript-<platform>-<arch>`
1358
+ // resolved from that manifest, then `lib/tsc` inside it.
1359
+ //
1360
+ // The install is chased to its real directory before the second hop, because
1361
+ // Node resolves a module's own dependencies from its real location. pnpm keeps
1362
+ // the real `typescript` directory in its content-addressed store with the
1363
+ // platform package beside it and leaves a link in the project's node_modules,
1364
+ // so a walk that started at the link would climb straight past the platform
1365
+ // package. NTFS junctions defeat filepath.EvalSymlinks, so the link component
1366
+ // is chased by hand first, the same order loaderTempBase uses.
1367
+ func tsgoBinaryFrom(anchor string) string {
1368
+ manifest := nodePackageManifestFrom(anchor, "typescript")
1369
+ if manifest == "" {
1370
+ return ""
1371
+ }
1372
+ packageDir := realpathIfPossible(resolveDirLink(filepath.Dir(manifest)))
1373
+ platform, arch := nodePlatformPair()
1374
+ platformManifest := nodePackageManifestFrom(
1375
+ filepath.Join(packageDir, "package.json"),
1376
+ "@typescript/typescript-"+platform+"-"+arch,
1377
+ )
1378
+ if platformManifest == "" {
1379
+ return ""
1380
+ }
1381
+ name := "tsc"
1382
+ if runtime.GOOS == "windows" {
1383
+ name = "tsc.exe"
1384
+ }
1385
+ binary := filepath.Join(filepath.Dir(platformManifest), "lib", name)
1386
+ if stat, err := os.Stat(binary); err != nil || stat.IsDir() {
1387
+ return ""
1388
+ }
1389
+ return binary
1390
+ }
1391
+
1392
+ // resolveTtsxLauncher returns the launcher ttsxCommandContext spawns.
1393
+ //
1394
+ // An explicit TTSC_TTSX_BINARY wins. Otherwise the launcher is derived from the
1395
+ // `ttsc` installation one of the anchors can see, because a bare command name
1396
+ // only works when a bin link happens to be on PATH — which it is for a global
1397
+ // install and is not for the ordinary project-local one. The bare `"ttsx"` name
1398
+ // remains the unchanged last resort for an installation no anchor reaches.
1399
+ func resolveTtsxLauncher(anchors []string) string {
1400
+ if explicit := strings.TrimSpace(os.Getenv("TTSC_TTSX_BINARY")); explicit != "" {
1401
+ return explicit
1402
+ }
1403
+ for _, anchor := range anchors {
1404
+ if launcher := ttsxLauncherFrom(anchor); launcher != "" {
1405
+ return launcher
1406
+ }
1407
+ }
1408
+ return "ttsx"
1409
+ }
1410
+
1411
+ // ttsxLauncherFrom returns `lib/launcher/ttsx.js` of the `ttsc` install
1412
+ // `anchor` can see, or "" when this anchor reaches no such install. Only the
1413
+ // manifest is an exported subpath, so the launcher is derived from where the
1414
+ // manifest resolved rather than requested as a subpath of its own.
1415
+ func ttsxLauncherFrom(anchor string) string {
1416
+ manifest := nodePackageManifestFrom(anchor, "ttsc")
1417
+ if manifest == "" {
1418
+ return ""
1419
+ }
1420
+ launcher := filepath.Join(filepath.Dir(manifest), "lib", "launcher", "ttsx.js")
1421
+ if stat, err := os.Stat(launcher); err != nil || stat.IsDir() {
1422
+ return ""
1423
+ }
1424
+ return launcher
1425
+ }
1426
+
1427
+ // nodePackageManifestFrom resolves `<pkg>/package.json` the way Node's
1428
+ // require.resolve does from the FILE `anchor`: walk upward from the anchor's
1429
+ // directory and return the first `<dir>/node_modules/<pkg>/package.json` that
1430
+ // exists. The anchor is treated as a file path, so its own directory is the
1431
+ // first candidate's parent, and it need not exist — Node derives the search
1432
+ // paths from the string alone.
1433
+ //
1434
+ // A directory already named `node_modules` contributes no candidate of its own,
1435
+ // matching Module._nodeModulePaths, so nothing ever resolves through
1436
+ // `node_modules/node_modules`.
1437
+ //
1438
+ // A relative anchor is resolved against the process directory before the walk,
1439
+ // again matching Node. Walking a relative path instead would terminate at "."
1440
+ // after one step and silently answer nothing for a config named relatively.
1441
+ func nodePackageManifestFrom(anchor, pkg string) string {
1442
+ if strings.TrimSpace(anchor) == "" || pkg == "" {
1443
+ return ""
1444
+ }
1445
+ if absolute, err := filepath.Abs(anchor); err == nil {
1446
+ anchor = absolute
1447
+ }
1448
+ dir := filepath.Dir(filepath.Clean(anchor))
1449
+ for {
1450
+ if filepath.Base(dir) != "node_modules" {
1451
+ candidate := filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")
1452
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
1453
+ return candidate
1454
+ }
1455
+ }
1456
+ parent := filepath.Dir(dir)
1457
+ if parent == dir {
1458
+ return ""
1459
+ }
1460
+ dir = parent
1461
+ }
1462
+ }
1463
+
1464
+ // nodePlatformPair is nodePlatformPairFor applied to this build's own target.
1465
+ func nodePlatformPair() (string, string) {
1466
+ return nodePlatformPairFor(runtime.GOOS, runtime.GOARCH)
1467
+ }
1468
+
1469
+ // nodePlatformPairFor maps a Go build target onto the `process.platform` and
1470
+ // `process.arch` pair npm spells a platform package with, so the package name
1471
+ // this plugin resolves is the same one the JS launcher resolves.
1472
+ //
1473
+ // Only the members whose two vocabularies disagree are mapped. Every other
1474
+ // value is identical on both sides and passes through, which keeps a target
1475
+ // neither side publishes yet resolvable rather than silently wrong, and keeps
1476
+ // this from becoming a list that has to grow with every new port.
1477
+ func nodePlatformPairFor(goos, goarch string) (string, string) {
1478
+ platform := goos
1479
+ switch platform {
1480
+ case "windows":
1481
+ platform = "win32"
1482
+ case "solaris":
1483
+ platform = "sunos"
1484
+ }
1485
+ arch := goarch
1486
+ switch arch {
1487
+ case "amd64":
1488
+ arch = "x64"
1489
+ case "386":
1490
+ arch = "ia32"
1491
+ case "ppc64le":
1492
+ arch = "ppc64"
1493
+ }
1494
+ return platform, arch
1495
+ }
1496
+
676
1497
  // 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
1498
+ // When the resolved launcher has a script extension (.js, .ts, …) the binary is
678
1499
  // 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...)
1500
+ func ttsxCommand(anchors []string, args ...string) *exec.Cmd {
1501
+ return ttsxCommandContext(context.Background(), anchors, args...)
681
1502
  }
682
1503
 
683
1504
  // ttsxCommandContext is the context-bound variant used by config loaders. It
684
1505
  // carries no deadline: evaluating a user config is the user's own code running,
685
1506
  // 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
- }
1507
+ //
1508
+ // `anchors` are the file paths the launcher is resolved from; see
1509
+ // resolveTtsxLauncher.
1510
+ func ttsxCommandContext(ctx context.Context, anchors []string, args ...string) *exec.Cmd {
1511
+ ttsx := resolveTtsxLauncher(anchors)
691
1512
  if shouldRunTtsxThroughNode(ttsx) {
692
1513
  node := os.Getenv("TTSC_NODE_BINARY")
693
1514
  if node == "" {
@@ -771,6 +1592,20 @@ func findNearestNodeModules(start string) string {
771
1592
  }
772
1593
  }
773
1594
 
1595
+ // relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
1596
+ // import specifier for location relative to fromDir.
1597
+ func relativeImportSpecifier(fromDir, location string) (string, error) {
1598
+ relative, err := filepath.Rel(fromDir, location)
1599
+ if err != nil {
1600
+ return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
1601
+ }
1602
+ relative = filepath.ToSlash(relative)
1603
+ if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
1604
+ return relative, nil
1605
+ }
1606
+ return "./" + relative, nil
1607
+ }
1608
+
774
1609
  // setEnv returns a copy of env with key=value. If key already exists in env,
775
1610
  // its value is updated in-place; otherwise the entry is appended.
776
1611
  func setEnv(env []string, key, value string) []string {
@@ -784,12 +1619,6 @@ func setEnv(env []string, key, value string) []string {
784
1619
  return append(env, prefix+value)
785
1620
  }
786
1621
 
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
1622
  // loaderFailureReason reads the failure envelope a config loader writes to its
794
1623
  // payload channel when it stops on an error it can name.
795
1624
  //
@@ -809,3 +1638,5 @@ func loaderFailureReason(output []byte) string {
809
1638
  }
810
1639
  return strings.TrimSpace(envelope.Message)
811
1640
  }
1641
+
1642
+ // ttsc:config-loader-shared end