@ttsc/banner 0.30.4 → 0.31.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
@@ -13,6 +13,7 @@ import (
13
13
  "strings"
14
14
 
15
15
  "github.com/samchon/ttsc/packages/ttsc/driver"
16
+ "github.com/samchon/ttsc/packages/ttsc/driver/resolutioninputs"
16
17
  "github.com/samchon/ttsc/packages/ttsc/driver/windowsjunction"
17
18
  )
18
19
 
@@ -23,13 +24,6 @@ func init() {
23
24
  // plugin implements driver.SourcePreamblePlugin for @ttsc/banner.
24
25
  type plugin struct{}
25
26
 
26
- var (
27
- // linkConfigNodeModules is overridable in tests to avoid real symlink creation.
28
- linkConfigNodeModules = linkNearestNodeModules
29
- // writeConfigLoaderFile is overridable in tests to avoid real file I/O.
30
- writeConfigLoaderFile = os.WriteFile
31
- )
32
-
33
27
  // frameworkKeys lists the tsconfig plugin-entry keys that the ttsc host
34
28
  // framework owns. They are accepted without error; all other keys are rejected.
35
29
  var frameworkKeys = map[string]struct{}{
@@ -60,9 +54,31 @@ func validateBannerConfig(config map[string]any) error {
60
54
  }
61
55
 
62
56
  // SourcePreamble resolves the banner text from the plugin config and returns it
63
- // formatted as a JSDoc block comment suitable for prepending to each emitted file.
57
+ // formatted as JSDoc source for the host to prepend before parsing.
58
+ //
59
+ // Failure returns no preamble and does not report dependency completeness.
60
+ // Successful evaluation reports universal config inputs because the same banner
61
+ // text applies to every file. The host invokes this operation once per Program,
62
+ // then its filesystem wrapper injects the returned text and applies emit policy.
63
+ //
64
+ // CRLF normalization, trailing blank-line removal and closing-delimiter escaping
65
+ // make consumer text a closed comment. The host owns placement, authored-position
66
+ // remapping and removeComments. Discovery probes seven names per ancestor, then
67
+ // parsing and formatting process the config/banner bytes linearly.
68
+ //
69
+ // JSON loads need no child. Executable config uses a child process and TypeScript
70
+ // also owns a temporary directory, removed by defer. stderr streams to the
71
+ // parent; stdout has no byte cap and evaluation has no deadline. Cleanup errors
72
+ // are not reported. Successful loader output must carry dependency observations;
73
+ // unsupported public resolution observation is reported separately from input
74
+ // mutation, so fresh evaluation can succeed without claiming reusable inputs.
75
+ //
76
+ // Native paths use filepath; slash conversion is reserved for import/tsconfig
77
+ // spelling. Child arguments are separate from executable paths. A loader's temp
78
+ // directory stays on the config volume, with Windows junctions used when native
79
+ // symlink creation is unavailable.
64
80
  func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
65
- preamble, err := parseBannerWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
81
+ preamble, err := parseBannerWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath, ctx.ReportObservationIncomplete)
66
82
  if err != nil {
67
83
  return "", err
68
84
  }
@@ -76,18 +92,13 @@ func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
76
92
  return preamble, nil
77
93
  }
78
94
 
79
- // parseBanner resolves and formats banner text into a JSDoc block comment.
80
- // Trailing blank lines are stripped from the resolved text before formatting.
81
- func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error) {
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)
95
+ // parseBannerWithReporters resolves banner text and formats it as a JSDoc block
96
+ // comment, stripping trailing blank lines first, while forwarding exact config
97
+ // witnesses.
98
+ // Optional incomplete reporters disclose unavailable public resolution observation
99
+ // without deleting known witnesses or treating an evaluated value as a failure.
100
+ func parseBannerWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string), incompleteReporters ...func()) (string, error) {
101
+ text, err := resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, hashReporter, realpathReporter, incompleteReporters...)
91
102
  if err != nil {
92
103
  return "", err
93
104
  }
@@ -118,24 +129,18 @@ func sanitizeJSDocLine(line string) string {
118
129
  return strings.ReplaceAll(line, "*/", "* /")
119
130
  }
120
131
 
121
- // resolveBannerText extracts the banner text from the plugin config.
122
- // The config entry is validated first: only the "configFile" key (plus
132
+ // resolveBannerTextWithReporters extracts the banner text from the plugin
133
+ // config. The entry is validated first: only the "configFile" key (plus
123
134
  // framework keys) is accepted. When "configFile" is present its value is
124
- // resolved to an absolute path and loaded. When absent the upward-walk
125
- // discovery is used. Returns an error when the config is invalid or when
126
- // no banner text can be found.
135
+ // resolved to an absolute path and loaded; when absent the upward-walk
136
+ // discovery is used. Returns an error when the config is invalid or no banner
137
+ // text can be found.
127
138
  //
128
- // The discovery base directory doubles as the resolution root the config
129
- // loader anchors its toolchain lookup on; see configToolAnchors.
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) {
139
+ // The discovery base directory doubles as the resolution root the config loader
140
+ // anchors its toolchain lookup on; see configToolAnchors. Configuration value
141
+ // loading stays separate from observation capability: incomplete reports cannot
142
+ // restore a missing input proof.
143
+ func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string), incompleteReporters ...func()) (string, error) {
139
144
  if err := validateBannerConfig(config); err != nil {
140
145
  return "", err
141
146
  }
@@ -152,6 +157,13 @@ func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath str
152
157
  return "", err
153
158
  }
154
159
  reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
160
+ if !loaded.complete {
161
+ for _, report := range incompleteReporters {
162
+ if report != nil {
163
+ report()
164
+ }
165
+ }
166
+ }
155
167
  text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
156
168
  if err != nil {
157
169
  return "", err
@@ -179,6 +191,13 @@ func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath str
179
191
  return "", err
180
192
  }
181
193
  reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
194
+ if !loaded.complete {
195
+ for _, report := range incompleteReporters {
196
+ if report != nil {
197
+ report()
198
+ }
199
+ }
200
+ }
182
201
  text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
183
202
  if err != nil {
184
203
  return "", err
@@ -214,7 +233,8 @@ func bannerTextFromConfigValue(raw any, label string) (string, bool, error) {
214
233
  return text, true, nil
215
234
  }
216
235
 
217
- // bannerConfigFilenames is the discovery name list, in precedence order.
236
+ // bannerConfigFilenames lists supported discovery names. Multiple files in one
237
+ // directory are ambiguous; this order does not select a preferred format.
218
238
  var bannerConfigFilenames = []string{
219
239
  "banner.config.json",
220
240
  "banner.config.js",
@@ -275,28 +295,26 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
275
295
  return driver.PluginConfigBaseDir(cwd, tsconfigPath)
276
296
  }
277
297
 
278
- // loadBannerConfigFile loads and evaluates a banner config file, returning its
279
- // exported value as a Go any. A valid banner config exports an object with a
280
- // "text" string; the value is validated by bannerTextFromConfigValue. The file
281
- // must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}; JS/CJS/MJS variants
282
- // run under Node, TypeScript variants compile and run via ttsx in a temp
283
- // directory, and JSON files are parsed natively.
284
- //
285
- // resolutionRoot is the project directory the TypeScript branch anchors its
286
- // toolchain resolution on when the config file's own ancestry answers nothing;
287
- // see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
288
- func loadBannerConfigFile(location, resolutionRoot string) (any, error) {
289
- loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
290
- return loaded.value, err
291
- }
292
-
293
298
  type bannerLoadedConfig struct {
299
+ // complete concerns resolution capability; missing per-input proofs still signal instability.
300
+ complete bool
301
+
294
302
  hashes map[string]*string
295
303
  inputs []string
296
304
  realpaths map[string]*string
297
305
  value any
298
306
  }
299
307
 
308
+ // loadBannerConfigFileWithInputs loads and evaluates a banner config file and
309
+ // returns its exported value with the input observations of the load. The file
310
+ // must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}; JS/CJS/MJS variants
311
+ // run under Node, TypeScript variants compile and run via ttsx in a temp
312
+ // directory, and JSON files are parsed natively. The value is validated by
313
+ // bannerTextFromConfigValue, not here.
314
+ //
315
+ // resolutionRoot is the project directory the TypeScript branch anchors its
316
+ // toolchain resolution on when the config file's own ancestry answers nothing;
317
+ // see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
300
318
  func loadBannerConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
301
319
  if !isBannerConfigFileName(filepath.Base(location)) {
302
320
  return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
@@ -310,7 +328,7 @@ func loadBannerConfigFileWithInputs(location, resolutionRoot string) (bannerLoad
310
328
  }
311
329
  value, err := parseBannerJSONConfigFile(location, body)
312
330
  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
331
+ return bannerLoadedConfig{complete: true, hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: physicalHostInput(location)}, value: value}, err
314
332
  case ".js", ".cjs", ".mjs":
315
333
  return loadBannerScriptConfigFileWithInputs(location)
316
334
  }
@@ -416,17 +434,10 @@ func isBannerConfigFileName(name string) bool {
416
434
  }
417
435
  }
418
436
 
419
- // loadBannerJSONConfigFile reads and JSON-parses a banner config file. A leading
420
- // UTF-8 BOM is stripped before parsing so files saved by Windows editors are
421
- // accepted. The parsed value must be an object with a non-empty "text" string.
422
- func loadBannerJSONConfigFile(location string) (any, error) {
423
- body, err := os.ReadFile(location)
424
- if err != nil {
425
- return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
426
- }
427
- return parseBannerJSONConfigFile(location, body)
428
- }
429
-
437
+ // parseBannerJSONConfigFile parses a banner config file already read from
438
+ // location. A leading UTF-8 BOM is stripped first so files saved by Windows
439
+ // editors are accepted; object and text validation belong to
440
+ // bannerTextFromConfigValue.
430
441
  func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
431
442
  // Strip a leading UTF-8 BOM so files saved by Windows editors round
432
443
  // trip through json.Unmarshal without an opaque "invalid character" failure.
@@ -438,239 +449,22 @@ func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
438
449
  return out, nil
439
450
  }
440
451
 
441
- // loadBannerScriptConfigFile evaluates a JS/CJS/MJS banner config file by
442
- // running a small Node.js loader script that dynamic-imports the file and
443
- // serializes its exported value to stdout as JSON.
444
- func loadBannerScriptConfigFile(location string) (any, error) {
445
- loaded, err := loadBannerScriptConfigFileWithInputs(location)
446
- return loaded.value, err
447
- }
448
-
452
+ // loadBannerScriptConfigFileWithInputs evaluates a JS/CJS/MJS banner config file
453
+ // by running a small Node.js loader script that dynamic-imports the file and
454
+ // serializes its exported value, with the loader's input observations, to stdout.
449
455
  func loadBannerScriptConfigFileWithInputs(location string) (bannerLoadedConfig, error) {
450
456
  const script = `
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
- };
457
+ const { pathToFileURL } = require("node:url");
458
+ // Which inputs a resolution read is the one rule ttsc owns
459
+ // (resolutioninputs.Recorder), evaluated before any hook is installed.
460
+ const { createResolutionInputRecorder, observeResolutions } = RESOLUTION_INPUT_RECORDER;
461
+ const recorder = createResolutionInputRecorder({ extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] });
462
+
463
+ recorder.recordFile(process.argv[1]);
464
+ // Every resolution the config makes is recorded: through a resolve hook, and
465
+ // on a runtime whose require.resolve bypasses the hooks, through the one
466
+ // entry point it reaches (resolutioninputs.Recorder, samchon/ttsc#1523).
467
+ observeResolutions(recorder);
674
468
 
675
469
  (async () => {
676
470
  const mod = await import(pathToFileURL(process.argv[1]).href);
@@ -687,8 +481,8 @@ nodeModule._resolveFilename = function resolveFilename(request, parent, isMain,
687
481
  }
688
482
  const value = typeof current === "function" ? await current() : current;
689
483
  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) }));
484
+ const recorded = recorder.finish();
485
+ process.stdout.write(JSON.stringify({ value: serializedValue, complete: recorded.complete, hashes: recorded.hashes, inputs: recorded.inputs, realpaths: recorded.realpaths }));
692
486
  })().catch((error) => {
693
487
  process.stderr.write(error && error.stack ? error.stack : String(error));
694
488
  process.exitCode = 1;
@@ -714,7 +508,10 @@ function toSerializableBanner(value) {
714
508
  // the loader runs. This preserves the historical process.argv layout seen by
715
509
  // both the loader and the imported user config without using string eval.
716
510
  cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
717
- cmd.Stdin = strings.NewReader("process.argv.splice(1, 1);\n" + script)
511
+ cmd.Stdin = strings.NewReader(
512
+ "process.argv.splice(1, 1);\nconst RESOLUTION_INPUT_RECORDER = " +
513
+ resolutioninputs.CommonJSExpression() + ";\n" + script,
514
+ )
718
515
  cmd.Env = nodeConfigLoaderEnv(location)
719
516
  // The child's stderr is human output and goes straight to this process's
720
517
  // stderr as it is written. Collecting it only to replay it afterwards is what
@@ -738,8 +535,14 @@ function toSerializableBanner(value) {
738
535
  return loaded, nil
739
536
  }
740
537
 
538
+ // decodeBannerConfigLoaderOutput requires the value and recorder snapshot from
539
+ // the executed loader. Accepting a bare value would report complete dependency
540
+ // observations even though no observations were returned. Missing probe values
541
+ // remain legitimate nullable entries inside the snapshot maps.
741
542
  func decodeBannerConfigLoaderOutput(output []byte) (bannerLoadedConfig, error) {
742
543
  var envelope struct {
544
+ Complete *bool `json:"complete"`
545
+
743
546
  Error string `json:"__ttscLoaderError"`
744
547
  Hashes map[string]*string `json:"hashes"`
745
548
  Inputs []string `json:"inputs"`
@@ -752,37 +555,25 @@ func decodeBannerConfigLoaderOutput(output []byte) (bannerLoadedConfig, error) {
752
555
  if envelope.Error != "" {
753
556
  return bannerLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
754
557
  }
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
558
+ if len(envelope.Value) == 0 || envelope.Complete == nil || envelope.Inputs == nil || envelope.Hashes == nil || envelope.Realpaths == nil {
559
+ return bannerLoadedConfig{}, fmt.Errorf("config loader must return a value with dependency observations")
764
560
  }
765
561
  var value any
766
562
  if err := json.Unmarshal(envelope.Value, &value); err != nil {
767
563
  return bannerLoadedConfig{}, err
768
564
  }
769
- return bannerLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
565
+ return bannerLoadedConfig{complete: *envelope.Complete, hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
770
566
  }
771
567
 
772
- // loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
773
- // file using ttsx in a temp directory. A symlink to the nearest node_modules
774
- // is created so the config file can import its own dependencies. The ttsx
775
- // build runs with `--no-plugins` so evaluating the config never triggers the
568
+ // loadBannerTypeScriptConfigFileWithInputs compiles and runs a TypeScript banner
569
+ // config file using ttsx in a temp directory. A symlink to the nearest
570
+ // node_modules is created so the config file can import its own dependencies. The
571
+ // ttsx build runs with --no-plugins so evaluating the config never triggers the
776
572
  // host project's transform/check plugins against the loader tsconfig.
777
573
  //
778
- // Both tools this spawns — the launcher and the compiler handed to it — are
779
- // resolved from the project rather than from the process environment alone;
780
- // see configToolAnchors.
781
- func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
782
- loaded, err := loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
783
- return loaded.value, err
784
- }
785
-
574
+ // Both tools this spawns, the launcher and the compiler handed to it, are
575
+ // resolved from the project rather than from the process environment alone; see
576
+ // configToolAnchors.
786
577
  func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
787
578
  tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
788
579
  if err != nil {
@@ -790,23 +581,12 @@ func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (
790
581
  }
791
582
  defer os.RemoveAll(tempDir)
792
583
 
793
- if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
794
- return bannerLoadedConfig{}, err
795
- }
796
-
797
- loader := filepath.Join(tempDir, "loader.mts")
798
- tsconfig := filepath.Join(tempDir, "tsconfig.json")
799
- importSpecifier, err := relativeImportSpecifier(tempDir, location)
584
+ loader, tsconfig, err := prepareBannerTypeScriptConfigLoader(
585
+ tempDir, location, linkNearestNodeModules, os.WriteFile,
586
+ )
800
587
  if err != nil {
801
588
  return bannerLoadedConfig{}, err
802
589
  }
803
- importLiteral, _ := json.Marshal(importSpecifier)
804
- if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
805
- return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
806
- }
807
- if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
808
- return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
809
- }
810
590
 
811
591
  args := []string{
812
592
  "--project", tsconfig,
@@ -846,264 +626,68 @@ func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (
846
626
  return loaded, nil
847
627
  }
848
628
 
849
- // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
850
- // module that imports the banner config file specified by importLiteral (a
851
- // JSON-encoded import specifier) and writes the serialized banner value to stdout.
852
- func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
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;
629
+ // prepareBannerTypeScriptConfigLoader owns the ordered preparation needed before
630
+ // the loader process can start: dependency links, resolution recorder, generated
631
+ // loader and tsconfig. Its caller supplies the invocation's filesystem operations
632
+ // explicitly and retains directory lifetime ownership. The native caller supplies
633
+ // linkNearestNodeModules and os.WriteFile; no mutable process-global hooks exist.
634
+ //
635
+ // A failed operation returns immediately with its original error context and
636
+ // never admits the process with partial preparation. Each fixed file is written
637
+ // once, native paths are joined through filepath, and helpers retain no state
638
+ // across invocations. I/O failure decisions can be verified at this owner without
639
+ // claiming that supplied callbacks establish actual kernel link capabilities.
640
+ func prepareBannerTypeScriptConfigLoader(
641
+ tempDir, location string,
642
+ linkNodeModules func(string, string) error,
643
+ writeFile func(string, []byte, os.FileMode) error,
644
+ ) (string, string, error) {
645
+ if err := linkNodeModules(tempDir, filepath.Dir(location)); err != nil {
646
+ return "", "", err
946
647
  }
947
- }
948
648
 
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;
649
+ loader := filepath.Join(tempDir, "loader.mts")
650
+ tsconfig := filepath.Join(tempDir, "tsconfig.json")
651
+ importSpecifier, err := relativeImportSpecifier(tempDir, location)
652
+ if err != nil {
653
+ return "", "", err
977
654
  }
978
- if (Array.isArray(value)) {
979
- for (const item of value) recordManifestTargets(item, directory, allowBare);
980
- return;
655
+ importLiteral, _ := json.Marshal(importSpecifier)
656
+ recorder := filepath.Join(tempDir, "ttsc-resolution-inputs.cjs")
657
+ if err := writeFile(recorder, []byte(resolutioninputs.Recorder), 0o644); err != nil {
658
+ return "", "", fmt.Errorf("@ttsc/banner: write config loader: %w", err)
981
659
  }
982
- if (value !== null && typeof value === "object") {
983
- for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
660
+ recorderLiteral, _ := json.Marshal(recorder)
661
+ if err := writeFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral), string(recorderLiteral))), 0o644); err != nil {
662
+ return "", "", fmt.Errorf("@ttsc/banner: write config loader: %w", err)
984
663
  }
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;
664
+ if err := writeFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
665
+ return "", "", fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
1049
666
  }
667
+
668
+ return loader, tsconfig, nil
1050
669
  }
1051
670
 
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
- });
671
+ // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
672
+ // module that imports the banner config file specified by importLiteral (a
673
+ // JSON-encoded import specifier) and writes the serialized banner value to stdout.
674
+ // recorderLiteral is the JSON-encoded path of the resolution input recorder
675
+ // written beside the loader (resolutioninputs.Recorder).
676
+ func bannerTypeScriptConfigLoaderSource(importLiteral, recorderLiteral string) string {
677
+ return fmt.Sprintf(`// @ts-nocheck
678
+ import { createRequire } from "node:module";
1064
679
 
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
- };
680
+ // Which inputs a resolution read is the one rule ttsc owns
681
+ // (resolutioninputs.Recorder), written beside this loader and required before
682
+ // any hook is installed, so the recorder itself is no input.
683
+ const recorderFile: string = %[2]s;
684
+ const { createResolutionInputRecorder, observeResolutions } = createRequire(recorderFile)(recorderFile);
685
+ const recorder = createResolutionInputRecorder({ extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] });
686
+
687
+ // Every resolution the config makes is recorded: through a resolve hook, and
688
+ // on a runtime whose require.resolve bypasses the hooks, through the one
689
+ // entry point it reaches (resolutioninputs.Recorder, samchon/ttsc#1523).
690
+ observeResolutions(recorder);
1107
691
 
1108
692
  declare const process: {
1109
693
  exitCode?: number;
@@ -1119,15 +703,16 @@ declare const process: {
1119
703
  // left for a trailing handler to settle.
1120
704
  (async () => {
1121
705
  try {
1122
- const importedConfig = await import(%s);
706
+ const importedConfig = await import(%[1]s);
1123
707
  const value = await resolveConfig(importedConfig);
1124
708
  const serializedValue = toSerializableBanner(value);
1125
- for (const input of [...inputs]) recordInput(input);
709
+ const recorded = recorder.finish();
1126
710
  process.stdout.write(JSON.stringify({
1127
711
  value: serializedValue,
1128
- hashes: Object.fromEntries(hashes),
1129
- inputs: [...inputs].sort(),
1130
- realpaths: Object.fromEntries(realpaths),
712
+ hashes: recorded.hashes,
713
+ complete: recorded.complete,
714
+ inputs: recorded.inputs,
715
+ realpaths: recorded.realpaths,
1131
716
  }));
1132
717
  } catch (error) {
1133
718
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
@@ -1181,7 +766,7 @@ function toSerializableBanner(value: unknown): unknown {
1181
766
  }
1182
767
  throw new Error("config file must export an object with a non-empty \"text\" string");
1183
768
  }
1184
- `, importLiteral)
769
+ `, importLiteral, recorderLiteral)
1185
770
  }
1186
771
 
1187
772
  // typeScriptConfigLoaderTsconfig returns the JSON content of a tsconfig that
@@ -1228,11 +813,8 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1228
813
  // packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
1229
814
  // decided against extracting it — the only home the three modules could share
1230
815
  // is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
1231
- // deliberately requires no in-tree ttsc module — and replaced the checklist
1232
- // with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
1233
- // between these markers across all three copies on every pull request, so
1234
- // editing one and not the others fails by name. That file's header carries the
1235
- // full decision and the rules for changing this block.
816
+ // deliberately requires no in-tree ttsc module — so a change to one copy must
817
+ // be made to all three.
1236
818
  //
1237
819
  // The code between the markers must stay identical. Comments may differ, the
1238
820
  // `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
@@ -1376,11 +958,11 @@ func realpathIfPossible(location string) string {
1376
958
  // the project being compiled, with an explicit environment variable winning
1377
959
  // and a last resort that invents no path.
1378
960
  //
1379
- // The three Go copies are held identical by the gate named at the top of this
1380
- // block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
1381
- // packages/lint/src/index.ts — is a fourth copy in another language that no Go
1382
- // gate can reach; what it owes is that both policies stay describable in one
1383
- // sentence.
961
+ // No automated check holds the three Go copies identical: a change lands in
962
+ // all three by hand, and each package's own config-loader tests exercise it.
963
+ // The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
964
+ // packages/lint/src/index.ts — is a fourth copy in another language; what it
965
+ // owes is that both policies stay describable in one sentence.
1384
966
  //
1385
967
  // The environment alone is the wrong place to ask. `ttsx` exports
1386
968
  // TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
@@ -1579,13 +1161,6 @@ func nodePlatformPairFor(goos, goarch string) (string, string) {
1579
1161
  return platform, arch
1580
1162
  }
1581
1163
 
1582
- // ttsxCommand builds an exec.Cmd that runs ttsx with the given args.
1583
- // When the resolved launcher has a script extension (.js, .ts, …) the binary is
1584
- // invoked via the Node runtime so it is executed correctly on all platforms.
1585
- func ttsxCommand(anchors []string, args ...string) *exec.Cmd {
1586
- return ttsxCommandContext(context.Background(), anchors, args...)
1587
- }
1588
-
1589
1164
  // ttsxCommandContext is the context-bound variant used by config loaders. It
1590
1165
  // carries no deadline: evaluating a user config is the user's own code running,
1591
1166
  // and how long that is allowed to take is not this binary's decision.
@@ -1691,8 +1266,8 @@ func relativeImportSpecifier(fromDir, location string) (string, error) {
1691
1266
  return "./" + relative, nil
1692
1267
  }
1693
1268
 
1694
- // setEnv returns a copy of env with key=value. If key already exists in env,
1695
- // its value is updated in-place; otherwise the entry is appended.
1269
+ // setEnv updates env with key=value. If the key already exists its entry is
1270
+ // replaced in-place; otherwise the entry is appended to the returned slice.
1696
1271
  func setEnv(env []string, key, value string) []string {
1697
1272
  prefix := key + "="
1698
1273
  for i, entry := range env {