@ttsc/banner 0.26.2 → 0.28.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,32 @@ 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
+ preamble, err := parseBannerWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
66
+ if err != nil {
67
+ return "", err
68
+ }
69
+ // Every file receives the same text, and that text comes from
70
+ // banner.config.* alone — including, for a script or TypeScript config, every
71
+ // module the loader pulled in, each of which was reported above as a host
72
+ // input. Host inputs stay universal under the completeness contract, so a
73
+ // config edit still invalidates every file while an unrelated type edit stops
74
+ // doing so (samchon/ttsc#1263).
75
+ ctx.ReportDependenciesComplete()
76
+ return preamble, nil
65
77
  }
66
78
 
67
79
  // parseBanner resolves and formats banner text into a JSDoc block comment.
68
80
  // Trailing blank lines are stripped from the resolved text before formatting.
69
81
  func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error) {
70
- text, err := resolveBannerText(config, cwd, tsconfigPath)
82
+ return parseBannerWithReporter(config, cwd, tsconfigPath, nil)
83
+ }
84
+
85
+ func parseBannerWithReporter(config map[string]any, cwd, tsconfigPath string, reporter func(string)) (string, error) {
86
+ return parseBannerWithReporters(config, cwd, tsconfigPath, reporter, nil, nil)
87
+ }
88
+
89
+ func parseBannerWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string)) (string, error) {
90
+ text, err := resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, hashReporter, realpathReporter)
71
91
  if err != nil {
72
92
  return "", err
73
93
  }
@@ -108,6 +128,14 @@ func sanitizeJSDocLine(line string) string {
108
128
  // The discovery base directory doubles as the resolution root the config
109
129
  // loader anchors its toolchain lookup on; see configToolAnchors.
110
130
  func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string, error) {
131
+ return resolveBannerTextWithReporter(config, cwd, tsconfigPath, nil)
132
+ }
133
+
134
+ func resolveBannerTextWithReporter(config map[string]any, cwd, tsconfigPath string, reporter func(string)) (string, error) {
135
+ return resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, nil, nil)
136
+ }
137
+
138
+ func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string)) (string, error) {
111
139
  if err := validateBannerConfig(config); err != nil {
112
140
  return "", err
113
141
  }
@@ -119,11 +147,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
119
147
  return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
120
148
  }
121
149
  location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
122
- raw, err := loadBannerConfigFile(location, resolutionRoot)
150
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
123
151
  if err != nil {
124
152
  return "", err
125
153
  }
126
- text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
154
+ reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
155
+ text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
127
156
  if err != nil {
128
157
  return "", err
129
158
  }
@@ -133,18 +162,24 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
133
162
  return text, nil
134
163
  }
135
164
 
136
- location, err := findBannerConfigFile(cwd, tsconfigPath)
165
+ location, probed, err := findBannerConfigFile(cwd, tsconfigPath)
166
+ // Report the rejected candidates before the error checks: a search that ended
167
+ // ambiguous or empty examined them just the same, and a consumer that learns
168
+ // of them can invalidate a generation the next search would answer
169
+ // differently.
170
+ driver.ReportRejectedConfigCandidates(probed, hashReporter, realpathReporter)
137
171
  if err != nil {
138
172
  return "", err
139
173
  }
140
174
  if location == "" {
141
175
  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
176
  }
143
- raw, err := loadBannerConfigFile(location, resolutionRoot)
177
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
144
178
  if err != nil {
145
179
  return "", err
146
180
  }
147
- text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
181
+ reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
182
+ text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
148
183
  if err != nil {
149
184
  return "", err
150
185
  }
@@ -179,47 +214,45 @@ func bannerTextFromConfigValue(raw any, label string) (string, bool, error) {
179
214
  return text, true, nil
180
215
  }
181
216
 
217
+ // bannerConfigFilenames is the discovery name list, in precedence order.
218
+ var bannerConfigFilenames = []string{
219
+ "banner.config.json",
220
+ "banner.config.js",
221
+ "banner.config.cjs",
222
+ "banner.config.mjs",
223
+ "banner.config.ts",
224
+ "banner.config.cts",
225
+ "banner.config.mts",
226
+ }
227
+
182
228
  // findBannerConfigFile walks up from the tsconfig (or cwd) directory looking for
183
229
  // a banner.config.{ts,cts,mts,js,cjs,mjs,json} file. Returns the path when exactly
184
230
  // one match is found per directory, "" when none exists at any level, or an
185
231
  // error when multiple candidates exist in the same directory.
186
- func findBannerConfigFile(cwd, tsconfigPath string) (string, error) {
187
- dir := tsconfigBaseDir(cwd, tsconfigPath)
188
- for {
189
- matches := make([]string, 0, 1)
190
- for _, name := range []string{
191
- "banner.config.json",
192
- "banner.config.js",
193
- "banner.config.cjs",
194
- "banner.config.mjs",
195
- "banner.config.ts",
196
- "banner.config.cts",
197
- "banner.config.mts",
198
- } {
199
- candidate := filepath.Join(dir, name)
200
- if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
201
- matches = append(matches, candidate)
202
- }
203
- }
204
- if len(matches) > 1 {
205
- names := make([]string, len(matches))
206
- for i, match := range matches {
207
- names[i] = filepath.Base(match)
208
- }
209
- return "", fmt.Errorf(
210
- "@ttsc/banner: multiple banner config files found in %s (%s); set \"configFile\" explicitly in the tsconfig plugin entry",
211
- dir, strings.Join(names, ", "),
212
- )
213
- }
214
- if len(matches) == 1 {
215
- return matches[0], nil
216
- }
217
- parent := filepath.Dir(dir)
218
- if parent == dir {
219
- return "", nil
232
+ //
233
+ // The second return value is every candidate the walk examined and rejected,
234
+ // each carrying whether it was absent or a directory wearing the name. Those
235
+ // paths decide the result as much as the file it returned: one
236
+ // created nearer the entry wins the next search outright, and one created
237
+ // beside the match makes that directory ambiguous. The caller reports them so a
238
+ // persistent consumer stops serving output built from a config a cold run would
239
+ // no longer choose (samchon/ttsc#1271).
240
+ func findBannerConfigFile(cwd, tsconfigPath string) (string, []driver.ConfigCandidate, error) {
241
+ discovery := driver.DiscoverConfigFile(tsconfigBaseDir(cwd, tsconfigPath), bannerConfigFilenames)
242
+ if len(discovery.Matches) > 1 {
243
+ names := make([]string, len(discovery.Matches))
244
+ for i, match := range discovery.Matches {
245
+ names[i] = filepath.Base(match)
220
246
  }
221
- dir = parent
247
+ return "", discovery.Probed, fmt.Errorf(
248
+ "@ttsc/banner: multiple banner config files found in %s (%s); set \"configFile\" explicitly in the tsconfig plugin entry",
249
+ discovery.Directory, strings.Join(names, ", "),
250
+ )
251
+ }
252
+ if len(discovery.Matches) == 1 {
253
+ return discovery.Matches[0], discovery.Probed, nil
222
254
  }
255
+ return "", discovery.Probed, nil
223
256
  }
224
257
 
225
258
  // resolveBannerConfigPath resolves a config path from the plugin entry.
@@ -253,17 +286,118 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
253
286
  // toolchain resolution on when the config file's own ancestry answers nothing;
254
287
  // see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
255
288
  func loadBannerConfigFile(location, resolutionRoot string) (any, error) {
289
+ loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
290
+ return loaded.value, err
291
+ }
292
+
293
+ type bannerLoadedConfig struct {
294
+ hashes map[string]*string
295
+ inputs []string
296
+ realpaths map[string]*string
297
+ value any
298
+ }
299
+
300
+ func loadBannerConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
256
301
  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)
302
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
258
303
  }
259
304
  ext := strings.ToLower(filepath.Ext(location))
260
305
  switch ext {
261
306
  case ".json":
262
- return loadBannerJSONConfigFile(location)
307
+ body, err := os.ReadFile(location)
308
+ if err != nil {
309
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
310
+ }
311
+ value, err := parseBannerJSONConfigFile(location, body)
312
+ digest := fmt.Sprintf("%x", sha256.Sum256(body))
313
+ return bannerLoadedConfig{hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: physicalHostInput(location)}, value: value}, err
263
314
  case ".js", ".cjs", ".mjs":
264
- return loadBannerScriptConfigFile(location)
315
+ return loadBannerScriptConfigFileWithInputs(location)
316
+ }
317
+ return loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
318
+ }
319
+
320
+ func reportBannerConfigInputs(inputs []string, hashes, realpaths map[string]*string, reporter func(string), hashReporter, realpathReporter func(string, *string)) {
321
+ if reporter == nil && hashReporter == nil && realpathReporter == nil {
322
+ return
323
+ }
324
+ for _, input := range inputs {
325
+ if reporter != nil {
326
+ reporter(input)
327
+ }
328
+ if hashReporter != nil {
329
+ if hash, ok := hashes[input]; ok {
330
+ hashReporter(input, hash)
331
+ }
332
+ }
333
+ if realpathReporter != nil {
334
+ if realpath, ok := realpaths[input]; ok {
335
+ realpathReporter(input, realpath)
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ func physicalHostInput(file string) *string {
342
+ resolved, err := filepath.Abs(file)
343
+ if err != nil {
344
+ return nil
345
+ }
346
+ resolved = filepath.Clean(resolved)
347
+ seen := make(map[string]struct{})
348
+ for range 255 {
349
+ if _, exists := seen[resolved]; exists {
350
+ return nil
351
+ }
352
+ seen[resolved] = struct{}{}
353
+ if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil {
354
+ evaluated, evalErr = filepath.Abs(evaluated)
355
+ if evalErr != nil {
356
+ return nil
357
+ }
358
+ evaluated = filepath.Clean(evaluated)
359
+ if _, statErr := os.Stat(evaluated); statErr != nil {
360
+ return nil
361
+ }
362
+ return &evaluated
363
+ }
364
+ next, ok := resolveHostInputLinkAncestor(resolved)
365
+ if !ok {
366
+ return nil
367
+ }
368
+ resolved = next
369
+ }
370
+ return nil
371
+ }
372
+
373
+ // resolveHostInputLinkAncestor follows the nearest link-like ancestor and
374
+ // reattaches its remaining suffix. Windows junction children can be opened and
375
+ // os.Readlink exposes the junction itself even when EvalSymlinks rejects the
376
+ // complete child path.
377
+ func resolveHostInputLinkAncestor(location string) (string, bool) {
378
+ probe := filepath.Clean(location)
379
+ suffix := make([]string, 0)
380
+ for {
381
+ if target, err := os.Readlink(probe); err == nil {
382
+ if !filepath.IsAbs(target) {
383
+ target = filepath.Join(filepath.Dir(probe), target)
384
+ }
385
+ for i := len(suffix) - 1; i >= 0; i-- {
386
+ target = filepath.Join(target, suffix[i])
387
+ }
388
+ absolute, absErr := filepath.Abs(target)
389
+ if absErr != nil {
390
+ return "", false
391
+ }
392
+ return filepath.Clean(absolute), true
393
+ }
394
+ parent := filepath.Dir(probe)
395
+ if parent == probe {
396
+ return "", false
397
+ }
398
+ suffix = append(suffix, filepath.Base(probe))
399
+ probe = parent
265
400
  }
266
- return loadBannerTypeScriptConfigFile(location, resolutionRoot)
267
401
  }
268
402
 
269
403
  // isBannerConfigFileName reports whether name is an allowed banner config file name.
@@ -290,6 +424,10 @@ func loadBannerJSONConfigFile(location string) (any, error) {
290
424
  if err != nil {
291
425
  return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
292
426
  }
427
+ return parseBannerJSONConfigFile(location, body)
428
+ }
429
+
430
+ func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
293
431
  // Strip a leading UTF-8 BOM so files saved by Windows editors round
294
432
  // trip through json.Unmarshal without an opaque "invalid character" failure.
295
433
  body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
@@ -304,8 +442,235 @@ func loadBannerJSONConfigFile(location string) (any, error) {
304
442
  // running a small Node.js loader script that dynamic-imports the file and
305
443
  // serializes its exported value to stdout as JSON.
306
444
  func loadBannerScriptConfigFile(location string) (any, error) {
445
+ loaded, err := loadBannerScriptConfigFileWithInputs(location)
446
+ return loaded.value, err
447
+ }
448
+
449
+ func loadBannerScriptConfigFileWithInputs(location string) (bannerLoadedConfig, error) {
307
450
  const script = `
308
- const { pathToFileURL } = require("node:url");
451
+ const nodeModule = require("node:module");
452
+ const { createRequire, isBuiltin, registerHooks } = nodeModule;
453
+ const crypto = require("node:crypto");
454
+ const fs = require("node:fs");
455
+ const path = require("node:path");
456
+ const { fileURLToPath, pathToFileURL } = require("node:url");
457
+ const inputs = new Set();
458
+ const hashes = new Map();
459
+ const realpaths = new Map();
460
+ const signatures = new Map();
461
+ const unstableHashes = new Set();
462
+
463
+ function existingFile(file) {
464
+ try { return fs.statSync(file).isFile(); }
465
+ catch { return false; }
466
+ }
467
+
468
+ function missingPathError(error) {
469
+ return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
470
+ }
471
+
472
+ function inputMetadataSignature(file) {
473
+ const requested = path.resolve(file);
474
+ let current = requested;
475
+ for (;;) {
476
+ try {
477
+ const link = fs.lstatSync(current, { bigint: true });
478
+ let target = link;
479
+ if (link.isSymbolicLink()) {
480
+ try { target = fs.statSync(current, { bigint: true }); }
481
+ catch { return undefined; }
482
+ }
483
+ 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(":");
484
+ } catch (error) {
485
+ if (!missingPathError(error)) return undefined;
486
+ const parent = path.dirname(current);
487
+ if (parent === current) return undefined;
488
+ current = parent;
489
+ }
490
+ }
491
+ }
492
+
493
+ function recordInput(file) {
494
+ file = path.resolve(file);
495
+ inputs.add(file);
496
+ if (unstableHashes.has(file)) return;
497
+ const beforeSignature = inputMetadataSignature(file);
498
+ let observed;
499
+ let observedRealpath;
500
+ 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"); }
501
+ catch { observed = null; }
502
+ try { observedRealpath = fs.realpathSync.native(file); }
503
+ catch { observedRealpath = null; }
504
+ const afterSignature = inputMetadataSignature(file);
505
+ 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)) {
506
+ hashes.delete(file);
507
+ realpaths.delete(file);
508
+ signatures.delete(file);
509
+ unstableHashes.add(file);
510
+ return;
511
+ }
512
+ signatures.set(file, afterSignature);
513
+ hashes.set(file, observed);
514
+ realpaths.set(file, observedRealpath);
515
+ }
516
+
517
+ function recordFile(file) {
518
+ const resolvedFile = path.resolve(file);
519
+ recordInput(resolvedFile);
520
+ for (let directory = path.dirname(resolvedFile);;) {
521
+ const manifest = path.join(directory, "package.json");
522
+ recordInput(manifest);
523
+ if (existingFile(manifest)) {
524
+ break;
525
+ }
526
+ const parent = path.dirname(directory);
527
+ if (parent === directory) {
528
+ break;
529
+ }
530
+ directory = parent;
531
+ }
532
+ }
533
+
534
+ function recordPackageManifests(file) {
535
+ for (let directory = path.dirname(path.resolve(file));;) {
536
+ const manifest = path.join(directory, "package.json");
537
+ recordInput(manifest);
538
+ if (existingFile(manifest)) return;
539
+ const parent = path.dirname(directory);
540
+ if (parent === directory) return;
541
+ directory = parent;
542
+ }
543
+ }
544
+
545
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];
546
+ function moduleCandidates(base) {
547
+ return [
548
+ base,
549
+ ...moduleProbeExtensions.map((extension) => base + extension),
550
+ path.join(base, "package.json"),
551
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
552
+ ];
553
+ }
554
+ const recordedModuleBases = new Set();
555
+ function recordManifestTargets(value, directory, allowBare = false) {
556
+ if (typeof value === "string") {
557
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
558
+ return;
559
+ }
560
+ if (Array.isArray(value)) {
561
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
562
+ return;
563
+ }
564
+ if (value && typeof value === "object") {
565
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
566
+ }
567
+ }
568
+ function recordModuleCandidates(base) {
569
+ const resolvedBase = path.resolve(base);
570
+ if (recordedModuleBases.has(resolvedBase)) return;
571
+ recordedModuleBases.add(resolvedBase);
572
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
573
+ try {
574
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
575
+ recordManifestTargets(manifest.exports, resolvedBase);
576
+ recordManifestTargets(manifest.module, resolvedBase, true);
577
+ recordManifestTargets(manifest.main, resolvedBase, true);
578
+ } catch {}
579
+ }
580
+ function candidateSelected(base, resolvedFile) {
581
+ for (const candidate of moduleCandidates(base)) {
582
+ try {
583
+ const canonical = fs.realpathSync.native(candidate);
584
+ const relative = path.relative(canonical, resolvedFile);
585
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
586
+ } catch {}
587
+ }
588
+ return false;
589
+ }
590
+ function localBases(specifier, parentDirectory) {
591
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
592
+ const raw = path.resolve(parentDirectory, specifier);
593
+ const suffixStart = specifier.search(/[?#]/);
594
+ if (suffixStart === -1) return [raw];
595
+ const pathname = specifier.slice(0, suffixStart);
596
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
597
+ }
598
+ function recordResolutionCandidates(specifier, parentURL, resolvedURL) {
599
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
600
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
601
+ let resolvedFile;
602
+ try {
603
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
604
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
605
+ : undefined;
606
+ } catch {}
607
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
608
+ try {
609
+ for (const base of localBases(specifier, parentDirectory)) {
610
+ recordPackageManifests(base);
611
+ let exact = false;
612
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
613
+ if (exact) recordInput(base);
614
+ else recordModuleCandidates(base);
615
+ }
616
+ } catch {}
617
+ return;
618
+ }
619
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
620
+ const parts = specifier.split("/");
621
+ const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
622
+ if (packageParts.some((part) => part === undefined || part === "")) return;
623
+ const packageName = packageParts.join("/");
624
+ const subpath = parts.slice(packageParts.length);
625
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
626
+ for (const searchPath of searchPaths) {
627
+ const packageDirectory = path.join(searchPath, packageName);
628
+ recordModuleCandidates(packageDirectory);
629
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
630
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
631
+ }
632
+ }
633
+
634
+ recordFile(process.argv[1]);
635
+ registerHooks({
636
+ resolve(specifier, context, nextResolve) {
637
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
638
+ const resolved = nextResolve(specifier, context);
639
+ const url = typeof resolved === "string" ? resolved : resolved && resolved.url;
640
+ recordResolutionCandidates(specifier, context.parentURL, url);
641
+ if (typeof url === "string" && url.startsWith("file:")) {
642
+ recordFile(fileURLToPath(url));
643
+ }
644
+ return resolved;
645
+ },
646
+ });
647
+
648
+ // The hook above never sees a require() made from inside a CommonJS module the
649
+ // ESM loader evaluated, which on Node 22 is every require the config makes:
650
+ // module.registerHooks observes the import() of that module and nothing within
651
+ // it. A config's own dependencies would then be reported without the candidates
652
+ // that decide them, so a spelling appearing later could change what the config
653
+ // resolves to with nothing in the envelope to notice it (samchon/ttsc#1280).
654
+ // Wrapping the CommonJS resolver records the same two observations the hook
655
+ // does, on the graph the hook cannot reach.
656
+ const nextResolveFilename = nodeModule._resolveFilename;
657
+ nodeModule._resolveFilename = function resolveFilename(request, parent, isMain, options) {
658
+ // _resolveFilename is an internal entry point anything may call, so a
659
+ // non-string request arrives here as readily as a specifier does. Reading it
660
+ // would replace Node's own argument error with a TypeError from this loader.
661
+ if (typeof request !== "string") {
662
+ return nextResolveFilename.call(this, request, parent, isMain, options);
663
+ }
664
+ const parentFile = parent && typeof parent.filename === "string" ? parent.filename : undefined;
665
+ const parentURL = parentFile === undefined ? undefined : pathToFileURL(parentFile).href;
666
+ recordResolutionCandidates(request, parentURL, undefined);
667
+ const resolved = nextResolveFilename.call(this, request, parent, isMain, options);
668
+ if (path.isAbsolute(resolved)) {
669
+ recordResolutionCandidates(request, parentURL, pathToFileURL(resolved).href);
670
+ recordFile(resolved);
671
+ }
672
+ return resolved;
673
+ };
309
674
 
310
675
  (async () => {
311
676
  const mod = await import(pathToFileURL(process.argv[1]).href);
@@ -321,15 +686,11 @@ const { pathToFileURL } = require("node:url");
321
686
  break;
322
687
  }
323
688
  const value = typeof current === "function" ? await current() : current;
324
- process.stdout.write(JSON.stringify(toSerializableBanner(value)));
689
+ const serializedValue = toSerializableBanner(value);
690
+ for (const input of [...inputs]) recordInput(input);
691
+ process.stdout.write(JSON.stringify({ value: serializedValue, hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
325
692
  })().catch((error) => {
326
693
  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
694
  process.exitCode = 1;
334
695
  process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
335
696
  });
@@ -347,7 +708,13 @@ function toSerializableBanner(value) {
347
708
  }
348
709
  ctx, cancel := context.WithCancel(context.Background())
349
710
  defer cancel()
350
- cmd := exec.CommandContext(ctx, node, "-e", script, location)
711
+ // Windows limits the whole process command line to roughly 32 KiB. The
712
+ // dependency-tracking loader is intentionally larger than that, so keep only
713
+ // an explicit CommonJS stdin program and remove Node's stdin sentinel before
714
+ // the loader runs. This preserves the historical process.argv layout seen by
715
+ // both the loader and the imported user config without using string eval.
716
+ cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
717
+ cmd.Stdin = strings.NewReader("process.argv.splice(1, 1);\n" + script)
351
718
  cmd.Env = nodeConfigLoaderEnv(location)
352
719
  // The child's stderr is human output and goes straight to this process's
353
720
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -360,15 +727,46 @@ function toSerializableBanner(value) {
360
727
  // What it could not put there is a reason a caller can act on, so that
361
728
  // arrives through the payload channel instead.
362
729
  if reason := loaderFailureReason(output); reason != "" {
363
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
730
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
364
731
  }
365
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
732
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
366
733
  }
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)
734
+ loaded, err := decodeBannerConfigLoaderOutput(output)
735
+ if err != nil {
736
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
370
737
  }
371
- return out, nil
738
+ return loaded, nil
739
+ }
740
+
741
+ func decodeBannerConfigLoaderOutput(output []byte) (bannerLoadedConfig, error) {
742
+ var envelope struct {
743
+ Error string `json:"__ttscLoaderError"`
744
+ Hashes map[string]*string `json:"hashes"`
745
+ Inputs []string `json:"inputs"`
746
+ Realpaths map[string]*string `json:"realpaths"`
747
+ Value json.RawMessage `json:"value"`
748
+ }
749
+ if err := json.Unmarshal(output, &envelope); err != nil {
750
+ return bannerLoadedConfig{}, err
751
+ }
752
+ if envelope.Error != "" {
753
+ return bannerLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
754
+ }
755
+ if len(envelope.Value) == 0 {
756
+ // Test/fallback launchers written against the historical payload return
757
+ // the config value directly. Preserve that accepted contract while real
758
+ // loaders use the envelope to carry runtime inputs.
759
+ var value any
760
+ if err := json.Unmarshal(output, &value); err != nil {
761
+ return bannerLoadedConfig{}, err
762
+ }
763
+ return bannerLoadedConfig{value: value}, nil
764
+ }
765
+ var value any
766
+ if err := json.Unmarshal(envelope.Value, &value); err != nil {
767
+ return bannerLoadedConfig{}, err
768
+ }
769
+ return bannerLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
372
770
  }
373
771
 
374
772
  // loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
@@ -381,28 +779,33 @@ function toSerializableBanner(value) {
381
779
  // resolved from the project rather than from the process environment alone;
382
780
  // see configToolAnchors.
383
781
  func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
782
+ loaded, err := loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
783
+ return loaded.value, err
784
+ }
785
+
786
+ func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
384
787
  tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
385
788
  if err != nil {
386
- return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
789
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
387
790
  }
388
791
  defer os.RemoveAll(tempDir)
389
792
 
390
793
  if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
391
- return nil, err
794
+ return bannerLoadedConfig{}, err
392
795
  }
393
796
 
394
797
  loader := filepath.Join(tempDir, "loader.mts")
395
798
  tsconfig := filepath.Join(tempDir, "tsconfig.json")
396
799
  importSpecifier, err := relativeImportSpecifier(tempDir, location)
397
800
  if err != nil {
398
- return nil, err
801
+ return bannerLoadedConfig{}, err
399
802
  }
400
803
  importLiteral, _ := json.Marshal(importSpecifier)
401
804
  if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
402
- return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
805
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
403
806
  }
404
807
  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)
808
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
406
809
  }
407
810
 
408
811
  args := []string{
@@ -432,22 +835,275 @@ func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error
432
835
  // What it could not put there is a reason a caller can act on, so that
433
836
  // arrives through the payload channel instead.
434
837
  if reason := loaderFailureReason(output); reason != "" {
435
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
838
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
436
839
  }
437
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
840
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
438
841
  }
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)
842
+ loaded, err := decodeBannerConfigLoaderOutput(output)
843
+ if err != nil {
844
+ return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
442
845
  }
443
- return out, nil
846
+ return loaded, nil
444
847
  }
445
848
 
446
849
  // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
447
850
  // module that imports the banner config file specified by importLiteral (a
448
851
  // JSON-encoded import specifier) and writes the serialized banner value to stdout.
449
852
  func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
450
- return fmt.Sprintf(`import * as importedConfig from %s;
853
+ return fmt.Sprintf(`// @ts-nocheck
854
+ import Module, { createRequire, isBuiltin, registerHooks } from "node:module";
855
+ import crypto from "node:crypto";
856
+ import fs from "node:fs";
857
+ import path from "node:path";
858
+ import { fileURLToPath, pathToFileURL } from "node:url";
859
+
860
+ const inputs = new Set<string>();
861
+ const hashes = new Map<string, string | null>();
862
+ const realpaths = new Map<string, string | null>();
863
+ const signatures = new Map<string, string>();
864
+ const unstableHashes = new Set<string>();
865
+
866
+ function existingFile(file: string): boolean {
867
+ try { return fs.statSync(file).isFile(); }
868
+ catch { return false; }
869
+ }
870
+
871
+ function missingPathError(error: unknown): boolean {
872
+ const code = (error as { code?: unknown } | undefined)?.code;
873
+ return code === "ENOENT" || code === "ENOTDIR";
874
+ }
875
+
876
+ function inputMetadataSignature(file: string): string | undefined {
877
+ const requested = path.resolve(file);
878
+ let current = requested;
879
+ for (;;) {
880
+ try {
881
+ const link = fs.lstatSync(current, { bigint: true });
882
+ let target = link;
883
+ if (link.isSymbolicLink()) {
884
+ try { target = fs.statSync(current, { bigint: true }); }
885
+ catch { return undefined; }
886
+ }
887
+ 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(":");
888
+ } catch (error) {
889
+ if (!missingPathError(error)) return undefined;
890
+ const parent = path.dirname(current);
891
+ if (parent === current) return undefined;
892
+ current = parent;
893
+ }
894
+ }
895
+ }
896
+
897
+ function recordInput(file: string): void {
898
+ file = path.resolve(file);
899
+ inputs.add(file);
900
+ if (unstableHashes.has(file)) return;
901
+ const beforeSignature = inputMetadataSignature(file);
902
+ let observed: string | null;
903
+ let observedRealpath: string | null;
904
+ 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"); }
905
+ catch { observed = null; }
906
+ try { observedRealpath = fs.realpathSync.native(file); }
907
+ catch { observedRealpath = null; }
908
+ const afterSignature = inputMetadataSignature(file);
909
+ 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)) {
910
+ hashes.delete(file);
911
+ realpaths.delete(file);
912
+ signatures.delete(file);
913
+ unstableHashes.add(file);
914
+ return;
915
+ }
916
+ signatures.set(file, afterSignature);
917
+ hashes.set(file, observed);
918
+ realpaths.set(file, observedRealpath);
919
+ }
920
+
921
+ function recordFile(file: string): void {
922
+ const resolvedFile = path.resolve(file);
923
+ recordInput(resolvedFile);
924
+ for (let directory = path.dirname(resolvedFile);;) {
925
+ const manifest = path.join(directory, "package.json");
926
+ recordInput(manifest);
927
+ if (existingFile(manifest)) {
928
+ break;
929
+ }
930
+ const parent = path.dirname(directory);
931
+ if (parent === directory) {
932
+ break;
933
+ }
934
+ directory = parent;
935
+ }
936
+ }
937
+
938
+ function recordPackageManifests(file: string): void {
939
+ for (let directory = path.dirname(path.resolve(file));;) {
940
+ const manifest = path.join(directory, "package.json");
941
+ recordInput(manifest);
942
+ if (existingFile(manifest)) return;
943
+ const parent = path.dirname(directory);
944
+ if (parent === directory) return;
945
+ directory = parent;
946
+ }
947
+ }
948
+
949
+ const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] as const;
950
+ const jsToTsProbeExtensions = new Map<string, readonly string[]>([
951
+ [".js", [".ts", ".tsx"]],
952
+ [".jsx", [".tsx"]],
953
+ [".mjs", [".mts"]],
954
+ [".cjs", [".cts"]],
955
+ ]);
956
+ function sourceSubstitutionCandidates(base: string): string[] {
957
+ const extension = path.extname(base).toLowerCase();
958
+ const substitutions = jsToTsProbeExtensions.get(extension);
959
+ if (substitutions === undefined) return [];
960
+ const stem = base.slice(0, base.length - extension.length);
961
+ return substitutions.map((candidate) => stem + candidate);
962
+ }
963
+ function moduleCandidates(base: string): string[] {
964
+ return [
965
+ base,
966
+ ...sourceSubstitutionCandidates(base),
967
+ ...moduleProbeExtensions.map((extension) => base + extension),
968
+ path.join(base, "package.json"),
969
+ ...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
970
+ ];
971
+ }
972
+ const recordedModuleBases = new Set<string>();
973
+ function recordManifestTargets(value: unknown, directory: string, allowBare: boolean = false): void {
974
+ if (typeof value === "string") {
975
+ if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
976
+ return;
977
+ }
978
+ if (Array.isArray(value)) {
979
+ for (const item of value) recordManifestTargets(item, directory, allowBare);
980
+ return;
981
+ }
982
+ if (value !== null && typeof value === "object") {
983
+ for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
984
+ }
985
+ }
986
+ function recordModuleCandidates(base: string): void {
987
+ const resolvedBase = path.resolve(base);
988
+ if (recordedModuleBases.has(resolvedBase)) return;
989
+ recordedModuleBases.add(resolvedBase);
990
+ for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
991
+ try {
992
+ const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
993
+ recordManifestTargets(manifest.exports, resolvedBase);
994
+ recordManifestTargets(manifest.module, resolvedBase, true);
995
+ recordManifestTargets(manifest.main, resolvedBase, true);
996
+ } catch {}
997
+ }
998
+ function candidateSelected(base: string, resolvedFile: string): boolean {
999
+ for (const candidate of moduleCandidates(base)) {
1000
+ try {
1001
+ const canonical = fs.realpathSync.native(candidate);
1002
+ const relative = path.relative(canonical, resolvedFile);
1003
+ if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
1004
+ } catch {}
1005
+ }
1006
+ return false;
1007
+ }
1008
+ function localBases(specifier: string, parentDirectory: string): string[] {
1009
+ if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
1010
+ const raw = path.resolve(parentDirectory, specifier);
1011
+ const suffixStart = specifier.search(/[?#]/);
1012
+ if (suffixStart === -1) return [raw];
1013
+ const pathname = specifier.slice(0, suffixStart);
1014
+ return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
1015
+ }
1016
+ function recordResolutionCandidates(specifier: string, parentURL: string | undefined, resolvedURL: string | undefined): void {
1017
+ if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
1018
+ const parentDirectory = path.dirname(fileURLToPath(parentURL));
1019
+ let resolvedFile: string | undefined;
1020
+ try {
1021
+ resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
1022
+ ? fs.realpathSync.native(fileURLToPath(resolvedURL))
1023
+ : undefined;
1024
+ } catch {}
1025
+ if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
1026
+ try {
1027
+ for (const base of localBases(specifier, parentDirectory)) {
1028
+ recordPackageManifests(base);
1029
+ let exact = false;
1030
+ try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
1031
+ if (exact) recordInput(base);
1032
+ else recordModuleCandidates(base);
1033
+ }
1034
+ } catch {}
1035
+ return;
1036
+ }
1037
+ if (isBuiltin(specifier) || specifier.startsWith("#")) return;
1038
+ const parts = specifier.split("/");
1039
+ const packageParts = parts[0]!.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
1040
+ if (packageParts.some((part) => part === undefined || part === "")) return;
1041
+ const packageName = packageParts.join("/");
1042
+ const subpath = parts.slice(packageParts.length);
1043
+ const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
1044
+ for (const searchPath of searchPaths) {
1045
+ const packageDirectory = path.join(searchPath, packageName);
1046
+ recordModuleCandidates(packageDirectory);
1047
+ if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
1048
+ if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
1049
+ }
1050
+ }
1051
+
1052
+ registerHooks({
1053
+ resolve(specifier, context, nextResolve) {
1054
+ recordResolutionCandidates(specifier, context.parentURL, undefined);
1055
+ const resolved = nextResolve(specifier, context);
1056
+ const url = typeof resolved === "string" ? resolved : resolved?.url;
1057
+ recordResolutionCandidates(specifier, context.parentURL, url);
1058
+ if (typeof url === "string" && url.startsWith("file:")) {
1059
+ recordFile(fileURLToPath(url));
1060
+ }
1061
+ return resolved;
1062
+ },
1063
+ });
1064
+
1065
+ // The hook above never sees a require() made from inside a CommonJS module the
1066
+ // ESM loader evaluated, which on Node 22 is every require the config makes:
1067
+ // module.registerHooks observes the import() of that module and nothing within
1068
+ // it. A config's own dependencies would then be reported without the candidates
1069
+ // that decide them, so a spelling appearing later could change what the config
1070
+ // resolves to with nothing in the envelope to notice it (samchon/ttsc#1280).
1071
+ // Wrapping the CommonJS resolver records the same two observations the hook
1072
+ // does, on the graph the hook cannot reach.
1073
+ const moduleInternals = Module as unknown as {
1074
+ _resolveFilename(
1075
+ request: string,
1076
+ parent: { filename?: string | null } | null | undefined,
1077
+ isMain: boolean,
1078
+ options?: unknown,
1079
+ ): string;
1080
+ };
1081
+ const nextResolveFilename = moduleInternals._resolveFilename;
1082
+ moduleInternals._resolveFilename = function resolveFilename(
1083
+ this: unknown,
1084
+ request: string,
1085
+ parent: { filename?: string | null } | null | undefined,
1086
+ isMain: boolean,
1087
+ options?: unknown,
1088
+ ): string {
1089
+ // _resolveFilename is an internal entry point anything may call, so a
1090
+ // non-string request arrives here as readily as a specifier does. Reading it
1091
+ // would replace Node's own argument error with a TypeError from this loader.
1092
+ if (typeof request !== "string") {
1093
+ return nextResolveFilename.call(this, request, parent, isMain, options);
1094
+ }
1095
+ const parentURL =
1096
+ typeof parent?.filename === "string"
1097
+ ? pathToFileURL(parent.filename).href
1098
+ : undefined;
1099
+ recordResolutionCandidates(request, parentURL, undefined);
1100
+ const resolved = nextResolveFilename.call(this, request, parent, isMain, options);
1101
+ if (path.isAbsolute(resolved)) {
1102
+ recordResolutionCandidates(request, parentURL, pathToFileURL(resolved).href);
1103
+ recordFile(resolved);
1104
+ }
1105
+ return resolved;
1106
+ };
451
1107
 
452
1108
  declare const process: {
453
1109
  exitCode?: number;
@@ -463,8 +1119,16 @@ declare const process: {
463
1119
  // left for a trailing handler to settle.
464
1120
  (async () => {
465
1121
  try {
1122
+ const importedConfig = await import(%s);
466
1123
  const value = await resolveConfig(importedConfig);
467
- process.stdout.write(JSON.stringify(toSerializableBanner(value)));
1124
+ const serializedValue = toSerializableBanner(value);
1125
+ for (const input of [...inputs]) recordInput(input);
1126
+ process.stdout.write(JSON.stringify({
1127
+ value: serializedValue,
1128
+ hashes: Object.fromEntries(hashes),
1129
+ inputs: [...inputs].sort(),
1130
+ realpaths: Object.fromEntries(realpaths),
1131
+ }));
468
1132
  } catch (error) {
469
1133
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
470
1134
  // The stack above is for the reader. This is for the caller: the parent
@@ -534,6 +1198,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
534
1198
  // resolving either way.
535
1199
  "module": configModuleOption(location),
536
1200
  "moduleResolution": "bundler",
1201
+ "jsx": "preserve",
537
1202
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
538
1203
  "rewriteRelativeImportExtensions": true,
539
1204
  "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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.26.2",
3
+ "version": "0.28.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.28.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
+ }