@ttsc/banner 0.27.0 → 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.
Files changed (2) hide show
  1. package/driver/banner.go +125 -40
  2. package/package.json +2 -2
package/driver/banner.go CHANGED
@@ -62,7 +62,18 @@ func validateBannerConfig(config map[string]any) error {
62
62
  // SourcePreamble resolves the banner text from the plugin config and returns it
63
63
  // formatted as a JSDoc block comment suitable for prepending to each emitted file.
64
64
  func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
65
- return parseBannerWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
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
66
77
  }
67
78
 
68
79
  // parseBanner resolves and formats banner text into a JSDoc block comment.
@@ -151,7 +162,12 @@ func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath str
151
162
  return text, nil
152
163
  }
153
164
 
154
- 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)
155
171
  if err != nil {
156
172
  return "", err
157
173
  }
@@ -198,47 +214,45 @@ func bannerTextFromConfigValue(raw any, label string) (string, bool, error) {
198
214
  return text, true, nil
199
215
  }
200
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
+
201
228
  // findBannerConfigFile walks up from the tsconfig (or cwd) directory looking for
202
229
  // a banner.config.{ts,cts,mts,js,cjs,mjs,json} file. Returns the path when exactly
203
230
  // one match is found per directory, "" when none exists at any level, or an
204
231
  // error when multiple candidates exist in the same directory.
205
- func findBannerConfigFile(cwd, tsconfigPath string) (string, error) {
206
- dir := tsconfigBaseDir(cwd, tsconfigPath)
207
- for {
208
- matches := make([]string, 0, 1)
209
- for _, name := range []string{
210
- "banner.config.json",
211
- "banner.config.js",
212
- "banner.config.cjs",
213
- "banner.config.mjs",
214
- "banner.config.ts",
215
- "banner.config.cts",
216
- "banner.config.mts",
217
- } {
218
- candidate := filepath.Join(dir, name)
219
- if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
220
- matches = append(matches, candidate)
221
- }
222
- }
223
- if len(matches) > 1 {
224
- names := make([]string, len(matches))
225
- for i, match := range matches {
226
- names[i] = filepath.Base(match)
227
- }
228
- return "", fmt.Errorf(
229
- "@ttsc/banner: multiple banner config files found in %s (%s); set \"configFile\" explicitly in the tsconfig plugin entry",
230
- dir, strings.Join(names, ", "),
231
- )
232
- }
233
- if len(matches) == 1 {
234
- return matches[0], nil
235
- }
236
- parent := filepath.Dir(dir)
237
- if parent == dir {
238
- 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)
239
246
  }
240
- 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
241
254
  }
255
+ return "", discovery.Probed, nil
242
256
  }
243
257
 
244
258
  // resolveBannerConfigPath resolves a config path from the plugin entry.
@@ -434,7 +448,8 @@ func loadBannerScriptConfigFile(location string) (any, error) {
434
448
 
435
449
  func loadBannerScriptConfigFileWithInputs(location string) (bannerLoadedConfig, error) {
436
450
  const script = `
437
- const { createRequire, isBuiltin, registerHooks } = require("node:module");
451
+ const nodeModule = require("node:module");
452
+ const { createRequire, isBuiltin, registerHooks } = nodeModule;
438
453
  const crypto = require("node:crypto");
439
454
  const fs = require("node:fs");
440
455
  const path = require("node:path");
@@ -630,6 +645,33 @@ registerHooks({
630
645
  },
631
646
  });
632
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
+ };
674
+
633
675
  (async () => {
634
676
  const mod = await import(pathToFileURL(process.argv[1]).href);
635
677
  let current = Object.prototype.hasOwnProperty.call(mod, "default") ? mod.default : mod;
@@ -809,11 +851,11 @@ func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (
809
851
  // JSON-encoded import specifier) and writes the serialized banner value to stdout.
810
852
  func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
811
853
  return fmt.Sprintf(`// @ts-nocheck
812
- import { createRequire, isBuiltin, registerHooks } from "node:module";
854
+ import Module, { createRequire, isBuiltin, registerHooks } from "node:module";
813
855
  import crypto from "node:crypto";
814
856
  import fs from "node:fs";
815
857
  import path from "node:path";
816
- import { fileURLToPath } from "node:url";
858
+ import { fileURLToPath, pathToFileURL } from "node:url";
817
859
 
818
860
  const inputs = new Set<string>();
819
861
  const hashes = new Map<string, string | null>();
@@ -1020,6 +1062,49 @@ registerHooks({
1020
1062
  },
1021
1063
  });
1022
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
+ };
1107
+
1023
1108
  declare const process: {
1024
1109
  exitCode?: number;
1025
1110
  stdout: { write(value: string, callback?: () => void): void };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.27.0",
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.27.0"
38
+ "ttsc": "0.28.0"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",