@ttsc/banner 0.26.2 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/driver/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
  }
@@ -108,6 +117,14 @@ func sanitizeJSDocLine(line string) string {
108
117
  // The discovery base directory doubles as the resolution root the config
109
118
  // loader anchors its toolchain lookup on; see configToolAnchors.
110
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) {
111
128
  if err := validateBannerConfig(config); err != nil {
112
129
  return "", err
113
130
  }
@@ -119,11 +136,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
119
136
  return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
120
137
  }
121
138
  location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
122
- raw, err := loadBannerConfigFile(location, resolutionRoot)
139
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
123
140
  if err != nil {
124
141
  return "", err
125
142
  }
126
- 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))
127
145
  if err != nil {
128
146
  return "", err
129
147
  }
@@ -140,11 +158,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
140
158
  if location == "" {
141
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")
142
160
  }
143
- raw, err := loadBannerConfigFile(location, resolutionRoot)
161
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
144
162
  if err != nil {
145
163
  return "", err
146
164
  }
147
- 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))
148
167
  if err != nil {
149
168
  return "", err
150
169
  }
@@ -253,17 +272,118 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
253
272
  // toolchain resolution on when the config file's own ancestry answers nothing;
254
273
  // see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
255
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) {
256
287
  if !isBannerConfigFileName(filepath.Base(location)) {
257
- 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)
258
289
  }
259
290
  ext := strings.ToLower(filepath.Ext(location))
260
291
  switch ext {
261
292
  case ".json":
262
- 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
263
300
  case ".js", ".cjs", ".mjs":
264
- 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
265
386
  }
266
- return loadBannerTypeScriptConfigFile(location, resolutionRoot)
267
387
  }
268
388
 
269
389
  // isBannerConfigFileName reports whether name is an allowed banner config file name.
@@ -290,6 +410,10 @@ func loadBannerJSONConfigFile(location string) (any, error) {
290
410
  if err != nil {
291
411
  return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
292
412
  }
413
+ return parseBannerJSONConfigFile(location, body)
414
+ }
415
+
416
+ func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
293
417
  // Strip a leading UTF-8 BOM so files saved by Windows editors round
294
418
  // trip through json.Unmarshal without an opaque "invalid character" failure.
295
419
  body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
@@ -304,8 +428,207 @@ func loadBannerJSONConfigFile(location string) (any, error) {
304
428
  // running a small Node.js loader script that dynamic-imports the file and
305
429
  // serializes its exported value to stdout as JSON.
306
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) {
307
436
  const script = `
308
- 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
+ });
309
632
 
310
633
  (async () => {
311
634
  const mod = await import(pathToFileURL(process.argv[1]).href);
@@ -321,15 +644,11 @@ const { pathToFileURL } = require("node:url");
321
644
  break;
322
645
  }
323
646
  const value = typeof current === "function" ? await current() : current;
324
- 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) }));
325
650
  })().catch((error) => {
326
651
  process.stderr.write(error && error.stack ? error.stack : String(error));
327
- // The stack above is for the reader. This is for the caller: the parent reads
328
- // stdout as the payload channel either way, so a failure reason travels as
329
- // data rather than as text scraped back out of a captured stream. The exit
330
- // code is set before the write so a callback that never fires still fails the
331
- // load, and the write's completion is what triggers the exit, because
332
- // process.exit abandons a pending pipe write.
333
652
  process.exitCode = 1;
334
653
  process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
335
654
  });
@@ -347,7 +666,13 @@ function toSerializableBanner(value) {
347
666
  }
348
667
  ctx, cancel := context.WithCancel(context.Background())
349
668
  defer cancel()
350
- 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)
351
676
  cmd.Env = nodeConfigLoaderEnv(location)
352
677
  // The child's stderr is human output and goes straight to this process's
353
678
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -360,15 +685,46 @@ function toSerializableBanner(value) {
360
685
  // What it could not put there is a reason a caller can act on, so that
361
686
  // arrives through the payload channel instead.
362
687
  if reason := loaderFailureReason(output); reason != "" {
363
- 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)
364
689
  }
365
- 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)
366
691
  }
367
- var out any
368
- if err := json.Unmarshal(output, &out); err != nil {
369
- 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)
370
695
  }
371
- 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
372
728
  }
373
729
 
374
730
  // loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
@@ -381,28 +737,33 @@ function toSerializableBanner(value) {
381
737
  // resolved from the project rather than from the process environment alone;
382
738
  // see configToolAnchors.
383
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) {
384
745
  tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
385
746
  if err != nil {
386
- 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)
387
748
  }
388
749
  defer os.RemoveAll(tempDir)
389
750
 
390
751
  if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
391
- return nil, err
752
+ return bannerLoadedConfig{}, err
392
753
  }
393
754
 
394
755
  loader := filepath.Join(tempDir, "loader.mts")
395
756
  tsconfig := filepath.Join(tempDir, "tsconfig.json")
396
757
  importSpecifier, err := relativeImportSpecifier(tempDir, location)
397
758
  if err != nil {
398
- return nil, err
759
+ return bannerLoadedConfig{}, err
399
760
  }
400
761
  importLiteral, _ := json.Marshal(importSpecifier)
401
762
  if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
402
- return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
763
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
403
764
  }
404
765
  if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
405
- 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)
406
767
  }
407
768
 
408
769
  args := []string{
@@ -432,22 +793,232 @@ func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error
432
793
  // What it could not put there is a reason a caller can act on, so that
433
794
  // arrives through the payload channel instead.
434
795
  if reason := loaderFailureReason(output); reason != "" {
435
- 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)
436
797
  }
437
- 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)
438
799
  }
439
- var out any
440
- if err := json.Unmarshal(output, &out); err != nil {
441
- return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
800
+ loaded, err := decodeBannerConfigLoaderOutput(output)
801
+ if err != nil {
802
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
442
803
  }
443
- return out, nil
804
+ return loaded, nil
444
805
  }
445
806
 
446
807
  // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
447
808
  // module that imports the banner config file specified by importLiteral (a
448
809
  // JSON-encoded import specifier) and writes the serialized banner value to stdout.
449
810
  func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
450
- 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
+ });
451
1022
 
452
1023
  declare const process: {
453
1024
  exitCode?: number;
@@ -463,8 +1034,16 @@ declare const process: {
463
1034
  // left for a trailing handler to settle.
464
1035
  (async () => {
465
1036
  try {
1037
+ const importedConfig = await import(%s);
466
1038
  const value = await resolveConfig(importedConfig);
467
- 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
+ }));
468
1047
  } catch (error) {
469
1048
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
470
1049
  // The stack above is for the reader. This is for the caller: the parent
@@ -534,6 +1113,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
534
1113
  // resolving either way.
535
1114
  "module": configModuleOption(location),
536
1115
  "moduleResolution": "bundler",
1116
+ "jsx": "preserve",
537
1117
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
538
1118
  "rewriteRelativeImportExtensions": true,
539
1119
  "rootDir": loaderRootDir(outDir),
package/lib/index.js CHANGED
@@ -18,6 +18,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.default = createTtscBanner;
21
+ const node_crypto_1 = __importDefault(require("node:crypto"));
22
+ const node_fs_1 = __importDefault(require("node:fs"));
21
23
  const node_path_1 = __importDefault(require("node:path"));
22
24
  __exportStar(require("./structures/index"), exports);
23
25
  /**
@@ -51,7 +53,11 @@ function createTtscBanner(context) {
51
53
  `The only accepted key in the tsconfig entry is "configFile" (optional path to the config file).`);
52
54
  }
53
55
  }
56
+ const configInputs = bannerConfigInputs(context);
54
57
  return {
58
+ hostInputHashes: configInputs.hashes,
59
+ hostInputRealpaths: configInputs.realpaths,
60
+ hostInputs: configInputs.inputs,
55
61
  name: "@ttsc/banner",
56
62
  // Point at the `driver/` directory one level above `lib/` in the
57
63
  // installed package tree (where the Go sources live). `context.dirname`
@@ -61,4 +67,83 @@ function createTtscBanner(context) {
61
67
  stage: "transform",
62
68
  };
63
69
  }
70
+ const BANNER_CONFIG_FILENAMES = [
71
+ "banner.config.json",
72
+ "banner.config.js",
73
+ "banner.config.cjs",
74
+ "banner.config.mjs",
75
+ "banner.config.ts",
76
+ "banner.config.cts",
77
+ "banner.config.mts",
78
+ ];
79
+ /** Mirror native config resolution while retaining missing priority probes. */
80
+ function bannerConfigInputs(context) {
81
+ const configFile = context.plugin.configFile;
82
+ const base = node_path_1.default.resolve(context.pluginConfigDir ?? node_path_1.default.dirname(context.tsconfig));
83
+ if (typeof configFile === "string" && configFile.trim() !== "") {
84
+ const file = node_path_1.default.isAbsolute(configFile)
85
+ ? node_path_1.default.resolve(configFile)
86
+ : node_path_1.default.resolve(base, configFile);
87
+ return {
88
+ hashes: { [file]: hostInputHash(file) },
89
+ inputs: [file],
90
+ realpaths: { [file]: hostInputRealpath(file) },
91
+ };
92
+ }
93
+ return configDiscoveryInputs(base, BANNER_CONFIG_FILENAMES);
94
+ }
95
+ function configDiscoveryInputs(base, names) {
96
+ const inputs = [];
97
+ const hashes = {};
98
+ const realpaths = {};
99
+ for (let directory = base;; directory = node_path_1.default.dirname(directory)) {
100
+ const candidates = names.map((name) => node_path_1.default.join(directory, name));
101
+ inputs.push(...candidates);
102
+ for (const candidate of candidates) {
103
+ hashes[candidate] = hostInputHash(candidate);
104
+ realpaths[candidate] = hostInputRealpath(candidate);
105
+ }
106
+ if (candidates.some(configCandidateExists))
107
+ break;
108
+ const parent = node_path_1.default.dirname(directory);
109
+ if (parent === directory)
110
+ break;
111
+ }
112
+ return { hashes, inputs, realpaths };
113
+ }
114
+ function hostInputRealpath(file) {
115
+ try {
116
+ return node_fs_1.default.realpathSync.native(file);
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ /** Hash the exact candidate state observed before discovery selects a file. */
123
+ function hostInputHash(file) {
124
+ try {
125
+ if (node_fs_1.default.statSync(file).isDirectory()) {
126
+ return node_crypto_1.default
127
+ .createHash("sha256")
128
+ .update("ttsc:host-input:directory\0")
129
+ .digest("hex");
130
+ }
131
+ return node_crypto_1.default
132
+ .createHash("sha256")
133
+ .update(node_fs_1.default.readFileSync(file))
134
+ .digest("hex");
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /** Match the native discovery rule: a directory is never a config file. */
141
+ function configCandidateExists(file) {
142
+ try {
143
+ return !node_fs_1.default.statSync(file).isDirectory();
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
64
149
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,0DAA6B;AAI7B,qDAAmC;AAmDnC;;;;GAIG;AACH,MAAM,cAAc,GAAG,IAAI,GAAG,CAAS;IACrC,SAAS;IACT,MAAM;IACN,OAAO;IACP,WAAW;CACZ,CAAC,CAAC;AAEH;;;;;;;;;;GAUG;AACH,0BACE,OAA0D;IAE1D,MAAM,KAAK,GAAG,OAAO,CAAC,MAAiC,CAAC;IACxD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACrC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,KAAK,YAAY,EAAE,CAAC;YACrD,MAAM,IAAI,KAAK,CACb,gEAAgE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI;gBACrF,sFAAsF;gBACtF,iGAAiG,CACpG,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,IAAI,EAAE,cAAc;QACpB,iEAAiE;QACjE,wEAAwE;QACxE,oEAAoE;QACpE,6EAA6E;QAC7E,MAAM,EAAE,mBAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC;QACrD,KAAK,EAAE,WAAW;KACnB,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,8DAAiC;AACjC,sDAAyB;AACzB,0DAA6B;AAI7B,qDAAmC;AA2DnC;;;;GAIG;AACH,MAAM,cAAc,GAAG,IAAI,GAAG,CAAS;IACrC,SAAS;IACT,MAAM;IACN,OAAO;IACP,WAAW;CACZ,CAAC,CAAC;AAEH;;;;;;;;;;GAUG;AACH,0BACE,OAA0D;IAE1D,MAAM,KAAK,GAAG,OAAO,CAAC,MAAiC,CAAC;IACxD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACrC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,KAAK,YAAY,EAAE,CAAC;YACrD,MAAM,IAAI,KAAK,CACb,gEAAgE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI;gBACrF,sFAAsF;gBACtF,iGAAiG,CACpG,CAAC;QACJ,CAAC;IACH,CAAC;IAED,MAAM,YAAY,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACjD,OAAO;QACL,eAAe,EAAE,YAAY,CAAC,MAAM;QACpC,kBAAkB,EAAE,YAAY,CAAC,SAAS;QAC1C,UAAU,EAAE,YAAY,CAAC,MAAM;QAC/B,IAAI,EAAE,cAAc;QACpB,iEAAiE;QACjE,wEAAwE;QACxE,oEAAoE;QACpE,6EAA6E;QAC7E,MAAM,EAAE,mBAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC;QACrD,KAAK,EAAE,WAAW;KACnB,CAAC;AACJ,CAAC;AAED,MAAM,uBAAuB,GAAG;IAC9B,oBAAoB;IACpB,kBAAkB;IAClB,mBAAmB;IACnB,mBAAmB;IACnB,kBAAkB;IAClB,mBAAmB;IACnB,mBAAmB;CACpB,CAAC;AAEF,+EAA+E;AAC/E,SAAS,kBAAkB,CACzB,OAA0D;IAM1D,MAAM,UAAU,GAAI,OAAO,CAAC,MAAmC,CAAC,UAAU,CAAC;IAC3E,MAAM,IAAI,GAAG,mBAAI,CAAC,OAAO,CACvB,OAAO,CAAC,eAAe,IAAI,mBAAI,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAC1D,CAAC;IACF,IAAI,OAAO,UAAU,KAAK,QAAQ,IAAI,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC/D,MAAM,IAAI,GAAG,mBAAI,CAAC,UAAU,CAAC,UAAU,CAAC;YACtC,CAAC,CAAC,mBAAI,CAAC,OAAO,CAAC,UAAU,CAAC;YAC1B,CAAC,CAAC,mBAAI,CAAC,OAAO,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QACnC,OAAO;YACL,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE;YACvC,MAAM,EAAE,CAAC,IAAI,CAAC;YACd,SAAS,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,iBAAiB,CAAC,IAAI,CAAC,EAAE;SAC/C,CAAC;IACJ,CAAC;IACD,OAAO,qBAAqB,CAAC,IAAI,EAAE,uBAAuB,CAAC,CAAC;AAC9D,CAAC;AAED,SAAS,qBAAqB,CAC5B,IAAY,EACZ,KAAwB;IAMxB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,MAAM,GAAkC,EAAE,CAAC;IACjD,MAAM,SAAS,GAAkC,EAAE,CAAC;IACpD,KAAK,IAAI,SAAS,GAAG,IAAI,GAAI,SAAS,GAAG,mBAAI,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;QACjE,MAAM,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,mBAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;QACnE,MAAM,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,CAAC;QAC3B,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACnC,MAAM,CAAC,SAAS,CAAC,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;YAC7C,SAAS,CAAC,SAAS,CAAC,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;QACtD,CAAC;QACD,IAAI,UAAU,CAAC,IAAI,CAAC,qBAAqB,CAAC;YAAE,MAAM;QAClD,MAAM,MAAM,GAAG,mBAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACvC,IAAI,MAAM,KAAK,SAAS;YAAE,MAAM;IAClC,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AACvC,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAY;IACrC,IAAI,CAAC;QACH,OAAO,iBAAE,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,SAAS,aAAa,CAAC,IAAY;IACjC,IAAI,CAAC;QACH,IAAI,iBAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;YACpC,OAAO,qBAAM;iBACV,UAAU,CAAC,QAAQ,CAAC;iBACpB,MAAM,CAAC,6BAA6B,CAAC;iBACrC,MAAM,CAAC,KAAK,CAAC,CAAC;QACnB,CAAC;QACD,OAAO,qBAAM;aACV,UAAU,CAAC,QAAQ,CAAC;aACpB,MAAM,CAAC,iBAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;aAC7B,MAAM,CAAC,KAAK,CAAC,CAAC;IACnB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,2EAA2E;AAC3E,SAAS,qBAAqB,CAAC,IAAY;IACzC,IAAI,CAAC;QACH,OAAO,CAAC,iBAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
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.2"
38
+ "ttsc": "0.27.0"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
1
3
  import path from "node:path";
2
4
 
3
5
  import type { ITtscBannerPluginConfig } from "./structures";
@@ -12,6 +14,12 @@ export * from "./structures/index";
12
14
  * executable sidecar or linked native source.
13
15
  */
14
16
  type TtscPluginDescriptor = {
17
+ /** Universal config-discovery inputs consumed by the native transform. */
18
+ hostInputs?: string[];
19
+ /** Evaluation-time fingerprints paired with {@link hostInputs}. */
20
+ hostInputHashes?: Record<string, string | null>;
21
+ /** Evaluation-time physical targets paired with {@link hostInputs}. */
22
+ hostInputRealpaths?: Record<string, string | null>;
15
23
  /** Human-readable plugin name used in logs and error messages. */
16
24
  name: string;
17
25
  /** Absolute path to the Go source directory for this plugin. */
@@ -45,6 +53,8 @@ type TtscPluginFactoryContext<TConfig> = {
45
53
  * replacement for `__filename`.
46
54
  */
47
55
  filename: string;
56
+ /** Host-declared anchor for implicit plugin config discovery. */
57
+ pluginConfigDir?: string;
48
58
  /** The raw plugin entry from `compilerOptions.plugins[]`. */
49
59
  plugin: TConfig;
50
60
  /** Absolute path to the project root (directory containing tsconfig). */
@@ -90,7 +100,11 @@ export default function createTtscBanner(
90
100
  }
91
101
  }
92
102
 
103
+ const configInputs = bannerConfigInputs(context);
93
104
  return {
105
+ hostInputHashes: configInputs.hashes,
106
+ hostInputRealpaths: configInputs.realpaths,
107
+ hostInputs: configInputs.inputs,
94
108
  name: "@ttsc/banner",
95
109
  // Point at the `driver/` directory one level above `lib/` in the
96
110
  // installed package tree (where the Go sources live). `context.dirname`
@@ -100,3 +114,98 @@ export default function createTtscBanner(
100
114
  stage: "transform",
101
115
  };
102
116
  }
117
+
118
+ const BANNER_CONFIG_FILENAMES = [
119
+ "banner.config.json",
120
+ "banner.config.js",
121
+ "banner.config.cjs",
122
+ "banner.config.mjs",
123
+ "banner.config.ts",
124
+ "banner.config.cts",
125
+ "banner.config.mts",
126
+ ];
127
+
128
+ /** Mirror native config resolution while retaining missing priority probes. */
129
+ function bannerConfigInputs(
130
+ context: TtscPluginFactoryContext<ITtscBannerPluginConfig>,
131
+ ): {
132
+ hashes: Record<string, string | null>;
133
+ inputs: string[];
134
+ realpaths: Record<string, string | null>;
135
+ } {
136
+ const configFile = (context.plugin as { configFile?: unknown }).configFile;
137
+ const base = path.resolve(
138
+ context.pluginConfigDir ?? path.dirname(context.tsconfig),
139
+ );
140
+ if (typeof configFile === "string" && configFile.trim() !== "") {
141
+ const file = path.isAbsolute(configFile)
142
+ ? path.resolve(configFile)
143
+ : path.resolve(base, configFile);
144
+ return {
145
+ hashes: { [file]: hostInputHash(file) },
146
+ inputs: [file],
147
+ realpaths: { [file]: hostInputRealpath(file) },
148
+ };
149
+ }
150
+ return configDiscoveryInputs(base, BANNER_CONFIG_FILENAMES);
151
+ }
152
+
153
+ function configDiscoveryInputs(
154
+ base: string,
155
+ names: readonly string[],
156
+ ): {
157
+ hashes: Record<string, string | null>;
158
+ inputs: string[];
159
+ realpaths: Record<string, string | null>;
160
+ } {
161
+ const inputs: string[] = [];
162
+ const hashes: Record<string, string | null> = {};
163
+ const realpaths: Record<string, string | null> = {};
164
+ for (let directory = base; ; directory = path.dirname(directory)) {
165
+ const candidates = names.map((name) => path.join(directory, name));
166
+ inputs.push(...candidates);
167
+ for (const candidate of candidates) {
168
+ hashes[candidate] = hostInputHash(candidate);
169
+ realpaths[candidate] = hostInputRealpath(candidate);
170
+ }
171
+ if (candidates.some(configCandidateExists)) break;
172
+ const parent = path.dirname(directory);
173
+ if (parent === directory) break;
174
+ }
175
+ return { hashes, inputs, realpaths };
176
+ }
177
+
178
+ function hostInputRealpath(file: string): string | null {
179
+ try {
180
+ return fs.realpathSync.native(file);
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /** Hash the exact candidate state observed before discovery selects a file. */
187
+ function hostInputHash(file: string): string | null {
188
+ try {
189
+ if (fs.statSync(file).isDirectory()) {
190
+ return crypto
191
+ .createHash("sha256")
192
+ .update("ttsc:host-input:directory\0")
193
+ .digest("hex");
194
+ }
195
+ return crypto
196
+ .createHash("sha256")
197
+ .update(fs.readFileSync(file))
198
+ .digest("hex");
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /** Match the native discovery rule: a directory is never a config file. */
205
+ function configCandidateExists(file: string): boolean {
206
+ try {
207
+ return !fs.statSync(file).isDirectory();
208
+ } catch {
209
+ return false;
210
+ }
211
+ }