@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 +739 -74
- package/lib/index.js +85 -0
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +109 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
123
151
|
if err != nil {
|
|
124
152
|
return "", err
|
|
125
153
|
}
|
|
126
|
-
|
|
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
|
-
|
|
177
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
144
178
|
if err != nil {
|
|
145
179
|
return "", err
|
|
146
180
|
}
|
|
147
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
730
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
|
|
364
731
|
}
|
|
365
|
-
return
|
|
732
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
|
|
366
733
|
}
|
|
367
|
-
|
|
368
|
-
if err
|
|
369
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
838
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
|
|
436
839
|
}
|
|
437
|
-
return
|
|
840
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
|
|
438
841
|
}
|
|
439
|
-
|
|
440
|
-
if err
|
|
441
|
-
return
|
|
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
|
|
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(
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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.
|
|
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
|
+
}
|