@ttsc/banner 0.26.2 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/driver/banner.go +618 -38
- 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,21 @@ func validateBannerConfig(config map[string]any) error {
|
|
|
61
62
|
// SourcePreamble resolves the banner text from the plugin config and returns it
|
|
62
63
|
// formatted as a JSDoc block comment suitable for prepending to each emitted file.
|
|
63
64
|
func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
|
|
64
|
-
return
|
|
65
|
+
return parseBannerWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath)
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// parseBanner resolves and formats banner text into a JSDoc block comment.
|
|
68
69
|
// Trailing blank lines are stripped from the resolved text before formatting.
|
|
69
70
|
func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error) {
|
|
70
|
-
|
|
71
|
+
return parseBannerWithReporter(config, cwd, tsconfigPath, nil)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func parseBannerWithReporter(config map[string]any, cwd, tsconfigPath string, reporter func(string)) (string, error) {
|
|
75
|
+
return parseBannerWithReporters(config, cwd, tsconfigPath, reporter, nil, nil)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func parseBannerWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string)) (string, error) {
|
|
79
|
+
text, err := resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, hashReporter, realpathReporter)
|
|
71
80
|
if err != nil {
|
|
72
81
|
return "", err
|
|
73
82
|
}
|
|
@@ -108,6 +117,14 @@ func sanitizeJSDocLine(line string) string {
|
|
|
108
117
|
// The discovery base directory doubles as the resolution root the config
|
|
109
118
|
// loader anchors its toolchain lookup on; see configToolAnchors.
|
|
110
119
|
func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string, error) {
|
|
120
|
+
return resolveBannerTextWithReporter(config, cwd, tsconfigPath, nil)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func resolveBannerTextWithReporter(config map[string]any, cwd, tsconfigPath string, reporter func(string)) (string, error) {
|
|
124
|
+
return resolveBannerTextWithReporters(config, cwd, tsconfigPath, reporter, nil, nil)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func resolveBannerTextWithReporters(config map[string]any, cwd, tsconfigPath string, reporter func(string), hashReporter func(string, *string), realpathReporter func(string, *string)) (string, error) {
|
|
111
128
|
if err := validateBannerConfig(config); err != nil {
|
|
112
129
|
return "", err
|
|
113
130
|
}
|
|
@@ -119,11 +136,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
|
|
|
119
136
|
return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
|
|
120
137
|
}
|
|
121
138
|
location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
|
|
122
|
-
|
|
139
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
123
140
|
if err != nil {
|
|
124
141
|
return "", err
|
|
125
142
|
}
|
|
126
|
-
|
|
143
|
+
reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
|
|
144
|
+
text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
|
|
127
145
|
if err != nil {
|
|
128
146
|
return "", err
|
|
129
147
|
}
|
|
@@ -140,11 +158,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
|
|
|
140
158
|
if location == "" {
|
|
141
159
|
return "", fmt.Errorf("@ttsc/banner: no banner.config.{ts,cts,mts,js,cjs,mjs,json} file found; create one or set \"configFile\" in the tsconfig plugin entry")
|
|
142
160
|
}
|
|
143
|
-
|
|
161
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
144
162
|
if err != nil {
|
|
145
163
|
return "", err
|
|
146
164
|
}
|
|
147
|
-
|
|
165
|
+
reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
|
|
166
|
+
text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
|
|
148
167
|
if err != nil {
|
|
149
168
|
return "", err
|
|
150
169
|
}
|
|
@@ -253,17 +272,118 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
|
|
|
253
272
|
// toolchain resolution on when the config file's own ancestry answers nothing;
|
|
254
273
|
// see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
|
|
255
274
|
func loadBannerConfigFile(location, resolutionRoot string) (any, error) {
|
|
275
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
276
|
+
return loaded.value, err
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
type bannerLoadedConfig struct {
|
|
280
|
+
hashes map[string]*string
|
|
281
|
+
inputs []string
|
|
282
|
+
realpaths map[string]*string
|
|
283
|
+
value any
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func loadBannerConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
|
|
256
287
|
if !isBannerConfigFileName(filepath.Base(location)) {
|
|
257
|
-
return
|
|
288
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
|
|
258
289
|
}
|
|
259
290
|
ext := strings.ToLower(filepath.Ext(location))
|
|
260
291
|
switch ext {
|
|
261
292
|
case ".json":
|
|
262
|
-
|
|
293
|
+
body, err := os.ReadFile(location)
|
|
294
|
+
if err != nil {
|
|
295
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
|
|
296
|
+
}
|
|
297
|
+
value, err := parseBannerJSONConfigFile(location, body)
|
|
298
|
+
digest := fmt.Sprintf("%x", sha256.Sum256(body))
|
|
299
|
+
return bannerLoadedConfig{hashes: map[string]*string{location: &digest}, inputs: []string{location}, realpaths: map[string]*string{location: physicalHostInput(location)}, value: value}, err
|
|
263
300
|
case ".js", ".cjs", ".mjs":
|
|
264
|
-
return
|
|
301
|
+
return loadBannerScriptConfigFileWithInputs(location)
|
|
302
|
+
}
|
|
303
|
+
return loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
func reportBannerConfigInputs(inputs []string, hashes, realpaths map[string]*string, reporter func(string), hashReporter, realpathReporter func(string, *string)) {
|
|
307
|
+
if reporter == nil && hashReporter == nil && realpathReporter == nil {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
for _, input := range inputs {
|
|
311
|
+
if reporter != nil {
|
|
312
|
+
reporter(input)
|
|
313
|
+
}
|
|
314
|
+
if hashReporter != nil {
|
|
315
|
+
if hash, ok := hashes[input]; ok {
|
|
316
|
+
hashReporter(input, hash)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if realpathReporter != nil {
|
|
320
|
+
if realpath, ok := realpaths[input]; ok {
|
|
321
|
+
realpathReporter(input, realpath)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
func physicalHostInput(file string) *string {
|
|
328
|
+
resolved, err := filepath.Abs(file)
|
|
329
|
+
if err != nil {
|
|
330
|
+
return nil
|
|
331
|
+
}
|
|
332
|
+
resolved = filepath.Clean(resolved)
|
|
333
|
+
seen := make(map[string]struct{})
|
|
334
|
+
for range 255 {
|
|
335
|
+
if _, exists := seen[resolved]; exists {
|
|
336
|
+
return nil
|
|
337
|
+
}
|
|
338
|
+
seen[resolved] = struct{}{}
|
|
339
|
+
if evaluated, evalErr := filepath.EvalSymlinks(resolved); evalErr == nil {
|
|
340
|
+
evaluated, evalErr = filepath.Abs(evaluated)
|
|
341
|
+
if evalErr != nil {
|
|
342
|
+
return nil
|
|
343
|
+
}
|
|
344
|
+
evaluated = filepath.Clean(evaluated)
|
|
345
|
+
if _, statErr := os.Stat(evaluated); statErr != nil {
|
|
346
|
+
return nil
|
|
347
|
+
}
|
|
348
|
+
return &evaluated
|
|
349
|
+
}
|
|
350
|
+
next, ok := resolveHostInputLinkAncestor(resolved)
|
|
351
|
+
if !ok {
|
|
352
|
+
return nil
|
|
353
|
+
}
|
|
354
|
+
resolved = next
|
|
355
|
+
}
|
|
356
|
+
return nil
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// resolveHostInputLinkAncestor follows the nearest link-like ancestor and
|
|
360
|
+
// reattaches its remaining suffix. Windows junction children can be opened and
|
|
361
|
+
// os.Readlink exposes the junction itself even when EvalSymlinks rejects the
|
|
362
|
+
// complete child path.
|
|
363
|
+
func resolveHostInputLinkAncestor(location string) (string, bool) {
|
|
364
|
+
probe := filepath.Clean(location)
|
|
365
|
+
suffix := make([]string, 0)
|
|
366
|
+
for {
|
|
367
|
+
if target, err := os.Readlink(probe); err == nil {
|
|
368
|
+
if !filepath.IsAbs(target) {
|
|
369
|
+
target = filepath.Join(filepath.Dir(probe), target)
|
|
370
|
+
}
|
|
371
|
+
for i := len(suffix) - 1; i >= 0; i-- {
|
|
372
|
+
target = filepath.Join(target, suffix[i])
|
|
373
|
+
}
|
|
374
|
+
absolute, absErr := filepath.Abs(target)
|
|
375
|
+
if absErr != nil {
|
|
376
|
+
return "", false
|
|
377
|
+
}
|
|
378
|
+
return filepath.Clean(absolute), true
|
|
379
|
+
}
|
|
380
|
+
parent := filepath.Dir(probe)
|
|
381
|
+
if parent == probe {
|
|
382
|
+
return "", false
|
|
383
|
+
}
|
|
384
|
+
suffix = append(suffix, filepath.Base(probe))
|
|
385
|
+
probe = parent
|
|
265
386
|
}
|
|
266
|
-
return loadBannerTypeScriptConfigFile(location, resolutionRoot)
|
|
267
387
|
}
|
|
268
388
|
|
|
269
389
|
// isBannerConfigFileName reports whether name is an allowed banner config file name.
|
|
@@ -290,6 +410,10 @@ func loadBannerJSONConfigFile(location string) (any, error) {
|
|
|
290
410
|
if err != nil {
|
|
291
411
|
return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
|
|
292
412
|
}
|
|
413
|
+
return parseBannerJSONConfigFile(location, body)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
|
|
293
417
|
// Strip a leading UTF-8 BOM so files saved by Windows editors round
|
|
294
418
|
// trip through json.Unmarshal without an opaque "invalid character" failure.
|
|
295
419
|
body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
|
|
@@ -304,8 +428,207 @@ func loadBannerJSONConfigFile(location string) (any, error) {
|
|
|
304
428
|
// running a small Node.js loader script that dynamic-imports the file and
|
|
305
429
|
// serializes its exported value to stdout as JSON.
|
|
306
430
|
func loadBannerScriptConfigFile(location string) (any, error) {
|
|
431
|
+
loaded, err := loadBannerScriptConfigFileWithInputs(location)
|
|
432
|
+
return loaded.value, err
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func loadBannerScriptConfigFileWithInputs(location string) (bannerLoadedConfig, error) {
|
|
307
436
|
const script = `
|
|
308
|
-
const {
|
|
437
|
+
const { createRequire, isBuiltin, registerHooks } = require("node:module");
|
|
438
|
+
const crypto = require("node:crypto");
|
|
439
|
+
const fs = require("node:fs");
|
|
440
|
+
const path = require("node:path");
|
|
441
|
+
const { fileURLToPath, pathToFileURL } = require("node:url");
|
|
442
|
+
const inputs = new Set();
|
|
443
|
+
const hashes = new Map();
|
|
444
|
+
const realpaths = new Map();
|
|
445
|
+
const signatures = new Map();
|
|
446
|
+
const unstableHashes = new Set();
|
|
447
|
+
|
|
448
|
+
function existingFile(file) {
|
|
449
|
+
try { return fs.statSync(file).isFile(); }
|
|
450
|
+
catch { return false; }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function missingPathError(error) {
|
|
454
|
+
return error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function inputMetadataSignature(file) {
|
|
458
|
+
const requested = path.resolve(file);
|
|
459
|
+
let current = requested;
|
|
460
|
+
for (;;) {
|
|
461
|
+
try {
|
|
462
|
+
const link = fs.lstatSync(current, { bigint: true });
|
|
463
|
+
let target = link;
|
|
464
|
+
if (link.isSymbolicLink()) {
|
|
465
|
+
try { target = fs.statSync(current, { bigint: true }); }
|
|
466
|
+
catch { return undefined; }
|
|
467
|
+
}
|
|
468
|
+
return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (!missingPathError(error)) return undefined;
|
|
471
|
+
const parent = path.dirname(current);
|
|
472
|
+
if (parent === current) return undefined;
|
|
473
|
+
current = parent;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function recordInput(file) {
|
|
479
|
+
file = path.resolve(file);
|
|
480
|
+
inputs.add(file);
|
|
481
|
+
if (unstableHashes.has(file)) return;
|
|
482
|
+
const beforeSignature = inputMetadataSignature(file);
|
|
483
|
+
let observed;
|
|
484
|
+
let observedRealpath;
|
|
485
|
+
try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
486
|
+
catch { observed = null; }
|
|
487
|
+
try { observedRealpath = fs.realpathSync.native(file); }
|
|
488
|
+
catch { observedRealpath = null; }
|
|
489
|
+
const afterSignature = inputMetadataSignature(file);
|
|
490
|
+
if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (signatures.has(file) && signatures.get(file) !== afterSignature) || (hashes.has(file) && hashes.get(file) !== observed) || (realpaths.has(file) && realpaths.get(file) !== observedRealpath)) {
|
|
491
|
+
hashes.delete(file);
|
|
492
|
+
realpaths.delete(file);
|
|
493
|
+
signatures.delete(file);
|
|
494
|
+
unstableHashes.add(file);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
signatures.set(file, afterSignature);
|
|
498
|
+
hashes.set(file, observed);
|
|
499
|
+
realpaths.set(file, observedRealpath);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function recordFile(file) {
|
|
503
|
+
const resolvedFile = path.resolve(file);
|
|
504
|
+
recordInput(resolvedFile);
|
|
505
|
+
for (let directory = path.dirname(resolvedFile);;) {
|
|
506
|
+
const manifest = path.join(directory, "package.json");
|
|
507
|
+
recordInput(manifest);
|
|
508
|
+
if (existingFile(manifest)) {
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
const parent = path.dirname(directory);
|
|
512
|
+
if (parent === directory) {
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
directory = parent;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function recordPackageManifests(file) {
|
|
520
|
+
for (let directory = path.dirname(path.resolve(file));;) {
|
|
521
|
+
const manifest = path.join(directory, "package.json");
|
|
522
|
+
recordInput(manifest);
|
|
523
|
+
if (existingFile(manifest)) return;
|
|
524
|
+
const parent = path.dirname(directory);
|
|
525
|
+
if (parent === directory) return;
|
|
526
|
+
directory = parent;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"];
|
|
531
|
+
function moduleCandidates(base) {
|
|
532
|
+
return [
|
|
533
|
+
base,
|
|
534
|
+
...moduleProbeExtensions.map((extension) => base + extension),
|
|
535
|
+
path.join(base, "package.json"),
|
|
536
|
+
...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
|
|
537
|
+
];
|
|
538
|
+
}
|
|
539
|
+
const recordedModuleBases = new Set();
|
|
540
|
+
function recordManifestTargets(value, directory, allowBare = false) {
|
|
541
|
+
if (typeof value === "string") {
|
|
542
|
+
if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (Array.isArray(value)) {
|
|
546
|
+
for (const item of value) recordManifestTargets(item, directory, allowBare);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (value && typeof value === "object") {
|
|
550
|
+
for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function recordModuleCandidates(base) {
|
|
554
|
+
const resolvedBase = path.resolve(base);
|
|
555
|
+
if (recordedModuleBases.has(resolvedBase)) return;
|
|
556
|
+
recordedModuleBases.add(resolvedBase);
|
|
557
|
+
for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
|
|
558
|
+
try {
|
|
559
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
|
|
560
|
+
recordManifestTargets(manifest.exports, resolvedBase);
|
|
561
|
+
recordManifestTargets(manifest.module, resolvedBase, true);
|
|
562
|
+
recordManifestTargets(manifest.main, resolvedBase, true);
|
|
563
|
+
} catch {}
|
|
564
|
+
}
|
|
565
|
+
function candidateSelected(base, resolvedFile) {
|
|
566
|
+
for (const candidate of moduleCandidates(base)) {
|
|
567
|
+
try {
|
|
568
|
+
const canonical = fs.realpathSync.native(candidate);
|
|
569
|
+
const relative = path.relative(canonical, resolvedFile);
|
|
570
|
+
if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
|
|
571
|
+
} catch {}
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
function localBases(specifier, parentDirectory) {
|
|
576
|
+
if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
|
|
577
|
+
const raw = path.resolve(parentDirectory, specifier);
|
|
578
|
+
const suffixStart = specifier.search(/[?#]/);
|
|
579
|
+
if (suffixStart === -1) return [raw];
|
|
580
|
+
const pathname = specifier.slice(0, suffixStart);
|
|
581
|
+
return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
|
|
582
|
+
}
|
|
583
|
+
function recordResolutionCandidates(specifier, parentURL, resolvedURL) {
|
|
584
|
+
if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
|
|
585
|
+
const parentDirectory = path.dirname(fileURLToPath(parentURL));
|
|
586
|
+
let resolvedFile;
|
|
587
|
+
try {
|
|
588
|
+
resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
|
|
589
|
+
? fs.realpathSync.native(fileURLToPath(resolvedURL))
|
|
590
|
+
: undefined;
|
|
591
|
+
} catch {}
|
|
592
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
|
|
593
|
+
try {
|
|
594
|
+
for (const base of localBases(specifier, parentDirectory)) {
|
|
595
|
+
recordPackageManifests(base);
|
|
596
|
+
let exact = false;
|
|
597
|
+
try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
|
|
598
|
+
if (exact) recordInput(base);
|
|
599
|
+
else recordModuleCandidates(base);
|
|
600
|
+
}
|
|
601
|
+
} catch {}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (isBuiltin(specifier) || specifier.startsWith("#")) return;
|
|
605
|
+
const parts = specifier.split("/");
|
|
606
|
+
const packageParts = parts[0].startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
|
|
607
|
+
if (packageParts.some((part) => part === undefined || part === "")) return;
|
|
608
|
+
const packageName = packageParts.join("/");
|
|
609
|
+
const subpath = parts.slice(packageParts.length);
|
|
610
|
+
const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
|
|
611
|
+
for (const searchPath of searchPaths) {
|
|
612
|
+
const packageDirectory = path.join(searchPath, packageName);
|
|
613
|
+
recordModuleCandidates(packageDirectory);
|
|
614
|
+
if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
|
|
615
|
+
if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
recordFile(process.argv[1]);
|
|
620
|
+
registerHooks({
|
|
621
|
+
resolve(specifier, context, nextResolve) {
|
|
622
|
+
recordResolutionCandidates(specifier, context.parentURL, undefined);
|
|
623
|
+
const resolved = nextResolve(specifier, context);
|
|
624
|
+
const url = typeof resolved === "string" ? resolved : resolved && resolved.url;
|
|
625
|
+
recordResolutionCandidates(specifier, context.parentURL, url);
|
|
626
|
+
if (typeof url === "string" && url.startsWith("file:")) {
|
|
627
|
+
recordFile(fileURLToPath(url));
|
|
628
|
+
}
|
|
629
|
+
return resolved;
|
|
630
|
+
},
|
|
631
|
+
});
|
|
309
632
|
|
|
310
633
|
(async () => {
|
|
311
634
|
const mod = await import(pathToFileURL(process.argv[1]).href);
|
|
@@ -321,15 +644,11 @@ const { pathToFileURL } = require("node:url");
|
|
|
321
644
|
break;
|
|
322
645
|
}
|
|
323
646
|
const value = typeof current === "function" ? await current() : current;
|
|
324
|
-
|
|
647
|
+
const serializedValue = toSerializableBanner(value);
|
|
648
|
+
for (const input of [...inputs]) recordInput(input);
|
|
649
|
+
process.stdout.write(JSON.stringify({ value: serializedValue, hashes: Object.fromEntries(hashes), inputs: [...inputs].sort(), realpaths: Object.fromEntries(realpaths) }));
|
|
325
650
|
})().catch((error) => {
|
|
326
651
|
process.stderr.write(error && error.stack ? error.stack : String(error));
|
|
327
|
-
// The stack above is for the reader. This is for the caller: the parent reads
|
|
328
|
-
// stdout as the payload channel either way, so a failure reason travels as
|
|
329
|
-
// data rather than as text scraped back out of a captured stream. The exit
|
|
330
|
-
// code is set before the write so a callback that never fires still fails the
|
|
331
|
-
// load, and the write's completion is what triggers the exit, because
|
|
332
|
-
// process.exit abandons a pending pipe write.
|
|
333
652
|
process.exitCode = 1;
|
|
334
653
|
process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
|
|
335
654
|
});
|
|
@@ -347,7 +666,13 @@ function toSerializableBanner(value) {
|
|
|
347
666
|
}
|
|
348
667
|
ctx, cancel := context.WithCancel(context.Background())
|
|
349
668
|
defer cancel()
|
|
350
|
-
|
|
669
|
+
// Windows limits the whole process command line to roughly 32 KiB. The
|
|
670
|
+
// dependency-tracking loader is intentionally larger than that, so keep only
|
|
671
|
+
// an explicit CommonJS stdin program and remove Node's stdin sentinel before
|
|
672
|
+
// the loader runs. This preserves the historical process.argv layout seen by
|
|
673
|
+
// both the loader and the imported user config without using string eval.
|
|
674
|
+
cmd := exec.CommandContext(ctx, node, "--input-type=commonjs", "-", location)
|
|
675
|
+
cmd.Stdin = strings.NewReader("process.argv.splice(1, 1);\n" + script)
|
|
351
676
|
cmd.Env = nodeConfigLoaderEnv(location)
|
|
352
677
|
// The child's stderr is human output and goes straight to this process's
|
|
353
678
|
// stderr as it is written. Collecting it only to replay it afterwards is what
|
|
@@ -360,15 +685,46 @@ function toSerializableBanner(value) {
|
|
|
360
685
|
// What it could not put there is a reason a caller can act on, so that
|
|
361
686
|
// arrives through the payload channel instead.
|
|
362
687
|
if reason := loaderFailureReason(output); reason != "" {
|
|
363
|
-
return
|
|
688
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
|
|
364
689
|
}
|
|
365
|
-
return
|
|
690
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
|
|
366
691
|
}
|
|
367
|
-
|
|
368
|
-
if err
|
|
369
|
-
return
|
|
692
|
+
loaded, err := decodeBannerConfigLoaderOutput(output)
|
|
693
|
+
if err != nil {
|
|
694
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
|
|
370
695
|
}
|
|
371
|
-
return
|
|
696
|
+
return loaded, nil
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
func decodeBannerConfigLoaderOutput(output []byte) (bannerLoadedConfig, error) {
|
|
700
|
+
var envelope struct {
|
|
701
|
+
Error string `json:"__ttscLoaderError"`
|
|
702
|
+
Hashes map[string]*string `json:"hashes"`
|
|
703
|
+
Inputs []string `json:"inputs"`
|
|
704
|
+
Realpaths map[string]*string `json:"realpaths"`
|
|
705
|
+
Value json.RawMessage `json:"value"`
|
|
706
|
+
}
|
|
707
|
+
if err := json.Unmarshal(output, &envelope); err != nil {
|
|
708
|
+
return bannerLoadedConfig{}, err
|
|
709
|
+
}
|
|
710
|
+
if envelope.Error != "" {
|
|
711
|
+
return bannerLoadedConfig{}, fmt.Errorf("%s", envelope.Error)
|
|
712
|
+
}
|
|
713
|
+
if len(envelope.Value) == 0 {
|
|
714
|
+
// Test/fallback launchers written against the historical payload return
|
|
715
|
+
// the config value directly. Preserve that accepted contract while real
|
|
716
|
+
// loaders use the envelope to carry runtime inputs.
|
|
717
|
+
var value any
|
|
718
|
+
if err := json.Unmarshal(output, &value); err != nil {
|
|
719
|
+
return bannerLoadedConfig{}, err
|
|
720
|
+
}
|
|
721
|
+
return bannerLoadedConfig{value: value}, nil
|
|
722
|
+
}
|
|
723
|
+
var value any
|
|
724
|
+
if err := json.Unmarshal(envelope.Value, &value); err != nil {
|
|
725
|
+
return bannerLoadedConfig{}, err
|
|
726
|
+
}
|
|
727
|
+
return bannerLoadedConfig{hashes: envelope.Hashes, inputs: envelope.Inputs, realpaths: envelope.Realpaths, value: value}, nil
|
|
372
728
|
}
|
|
373
729
|
|
|
374
730
|
// loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
|
|
@@ -381,28 +737,33 @@ function toSerializableBanner(value) {
|
|
|
381
737
|
// resolved from the project rather than from the process environment alone;
|
|
382
738
|
// see configToolAnchors.
|
|
383
739
|
func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error) {
|
|
740
|
+
loaded, err := loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot)
|
|
741
|
+
return loaded.value, err
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
func loadBannerTypeScriptConfigFileWithInputs(location, resolutionRoot string) (bannerLoadedConfig, error) {
|
|
384
745
|
tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
|
|
385
746
|
if err != nil {
|
|
386
|
-
return
|
|
747
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
|
|
387
748
|
}
|
|
388
749
|
defer os.RemoveAll(tempDir)
|
|
389
750
|
|
|
390
751
|
if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
|
|
391
|
-
return
|
|
752
|
+
return bannerLoadedConfig{}, err
|
|
392
753
|
}
|
|
393
754
|
|
|
394
755
|
loader := filepath.Join(tempDir, "loader.mts")
|
|
395
756
|
tsconfig := filepath.Join(tempDir, "tsconfig.json")
|
|
396
757
|
importSpecifier, err := relativeImportSpecifier(tempDir, location)
|
|
397
758
|
if err != nil {
|
|
398
|
-
return
|
|
759
|
+
return bannerLoadedConfig{}, err
|
|
399
760
|
}
|
|
400
761
|
importLiteral, _ := json.Marshal(importSpecifier)
|
|
401
762
|
if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
|
|
402
|
-
return
|
|
763
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
|
|
403
764
|
}
|
|
404
765
|
if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
|
|
405
|
-
return
|
|
766
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
|
|
406
767
|
}
|
|
407
768
|
|
|
408
769
|
args := []string{
|
|
@@ -432,22 +793,232 @@ func loadBannerTypeScriptConfigFile(location, resolutionRoot string) (any, error
|
|
|
432
793
|
// What it could not put there is a reason a caller can act on, so that
|
|
433
794
|
// arrives through the payload channel instead.
|
|
434
795
|
if reason := loaderFailureReason(output); reason != "" {
|
|
435
|
-
return
|
|
796
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
|
|
436
797
|
}
|
|
437
|
-
return
|
|
798
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
|
|
438
799
|
}
|
|
439
|
-
|
|
440
|
-
if err
|
|
441
|
-
return
|
|
800
|
+
loaded, err := decodeBannerConfigLoaderOutput(output)
|
|
801
|
+
if err != nil {
|
|
802
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
|
|
442
803
|
}
|
|
443
|
-
return
|
|
804
|
+
return loaded, nil
|
|
444
805
|
}
|
|
445
806
|
|
|
446
807
|
// bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
|
|
447
808
|
// module that imports the banner config file specified by importLiteral (a
|
|
448
809
|
// JSON-encoded import specifier) and writes the serialized banner value to stdout.
|
|
449
810
|
func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
|
|
450
|
-
return fmt.Sprintf(
|
|
811
|
+
return fmt.Sprintf(`// @ts-nocheck
|
|
812
|
+
import { createRequire, isBuiltin, registerHooks } from "node:module";
|
|
813
|
+
import crypto from "node:crypto";
|
|
814
|
+
import fs from "node:fs";
|
|
815
|
+
import path from "node:path";
|
|
816
|
+
import { fileURLToPath } from "node:url";
|
|
817
|
+
|
|
818
|
+
const inputs = new Set<string>();
|
|
819
|
+
const hashes = new Map<string, string | null>();
|
|
820
|
+
const realpaths = new Map<string, string | null>();
|
|
821
|
+
const signatures = new Map<string, string>();
|
|
822
|
+
const unstableHashes = new Set<string>();
|
|
823
|
+
|
|
824
|
+
function existingFile(file: string): boolean {
|
|
825
|
+
try { return fs.statSync(file).isFile(); }
|
|
826
|
+
catch { return false; }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function missingPathError(error: unknown): boolean {
|
|
830
|
+
const code = (error as { code?: unknown } | undefined)?.code;
|
|
831
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function inputMetadataSignature(file: string): string | undefined {
|
|
835
|
+
const requested = path.resolve(file);
|
|
836
|
+
let current = requested;
|
|
837
|
+
for (;;) {
|
|
838
|
+
try {
|
|
839
|
+
const link = fs.lstatSync(current, { bigint: true });
|
|
840
|
+
let target = link;
|
|
841
|
+
if (link.isSymbolicLink()) {
|
|
842
|
+
try { target = fs.statSync(current, { bigint: true }); }
|
|
843
|
+
catch { return undefined; }
|
|
844
|
+
}
|
|
845
|
+
return [path.relative(current, requested), link.dev, link.ino, link.mode, link.size, link.mtimeNs, link.ctimeNs, target.dev, target.ino, target.mode, target.size, target.mtimeNs, target.ctimeNs].join(":");
|
|
846
|
+
} catch (error) {
|
|
847
|
+
if (!missingPathError(error)) return undefined;
|
|
848
|
+
const parent = path.dirname(current);
|
|
849
|
+
if (parent === current) return undefined;
|
|
850
|
+
current = parent;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function recordInput(file: string): void {
|
|
856
|
+
file = path.resolve(file);
|
|
857
|
+
inputs.add(file);
|
|
858
|
+
if (unstableHashes.has(file)) return;
|
|
859
|
+
const beforeSignature = inputMetadataSignature(file);
|
|
860
|
+
let observed: string | null;
|
|
861
|
+
let observedRealpath: string | null;
|
|
862
|
+
try { observed = fs.statSync(file).isDirectory() ? crypto.createHash("sha256").update("ttsc:host-input:directory\\0").digest("hex") : crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
863
|
+
catch { observed = null; }
|
|
864
|
+
try { observedRealpath = fs.realpathSync.native(file); }
|
|
865
|
+
catch { observedRealpath = null; }
|
|
866
|
+
const afterSignature = inputMetadataSignature(file);
|
|
867
|
+
if (beforeSignature === undefined || afterSignature === undefined || beforeSignature !== afterSignature || (signatures.has(file) && signatures.get(file) !== afterSignature) || (hashes.has(file) && hashes.get(file) !== observed) || (realpaths.has(file) && realpaths.get(file) !== observedRealpath)) {
|
|
868
|
+
hashes.delete(file);
|
|
869
|
+
realpaths.delete(file);
|
|
870
|
+
signatures.delete(file);
|
|
871
|
+
unstableHashes.add(file);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
signatures.set(file, afterSignature);
|
|
875
|
+
hashes.set(file, observed);
|
|
876
|
+
realpaths.set(file, observedRealpath);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function recordFile(file: string): void {
|
|
880
|
+
const resolvedFile = path.resolve(file);
|
|
881
|
+
recordInput(resolvedFile);
|
|
882
|
+
for (let directory = path.dirname(resolvedFile);;) {
|
|
883
|
+
const manifest = path.join(directory, "package.json");
|
|
884
|
+
recordInput(manifest);
|
|
885
|
+
if (existingFile(manifest)) {
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
const parent = path.dirname(directory);
|
|
889
|
+
if (parent === directory) {
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
directory = parent;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function recordPackageManifests(file: string): void {
|
|
897
|
+
for (let directory = path.dirname(path.resolve(file));;) {
|
|
898
|
+
const manifest = path.join(directory, "package.json");
|
|
899
|
+
recordInput(manifest);
|
|
900
|
+
if (existingFile(manifest)) return;
|
|
901
|
+
const parent = path.dirname(directory);
|
|
902
|
+
if (parent === directory) return;
|
|
903
|
+
directory = parent;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const moduleProbeExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json", ".node"] as const;
|
|
908
|
+
const jsToTsProbeExtensions = new Map<string, readonly string[]>([
|
|
909
|
+
[".js", [".ts", ".tsx"]],
|
|
910
|
+
[".jsx", [".tsx"]],
|
|
911
|
+
[".mjs", [".mts"]],
|
|
912
|
+
[".cjs", [".cts"]],
|
|
913
|
+
]);
|
|
914
|
+
function sourceSubstitutionCandidates(base: string): string[] {
|
|
915
|
+
const extension = path.extname(base).toLowerCase();
|
|
916
|
+
const substitutions = jsToTsProbeExtensions.get(extension);
|
|
917
|
+
if (substitutions === undefined) return [];
|
|
918
|
+
const stem = base.slice(0, base.length - extension.length);
|
|
919
|
+
return substitutions.map((candidate) => stem + candidate);
|
|
920
|
+
}
|
|
921
|
+
function moduleCandidates(base: string): string[] {
|
|
922
|
+
return [
|
|
923
|
+
base,
|
|
924
|
+
...sourceSubstitutionCandidates(base),
|
|
925
|
+
...moduleProbeExtensions.map((extension) => base + extension),
|
|
926
|
+
path.join(base, "package.json"),
|
|
927
|
+
...moduleProbeExtensions.map((extension) => path.join(base, "index" + extension)),
|
|
928
|
+
];
|
|
929
|
+
}
|
|
930
|
+
const recordedModuleBases = new Set<string>();
|
|
931
|
+
function recordManifestTargets(value: unknown, directory: string, allowBare: boolean = false): void {
|
|
932
|
+
if (typeof value === "string") {
|
|
933
|
+
if (value !== "" && (allowBare || value.startsWith("./") || value.startsWith("../"))) recordModuleCandidates(path.resolve(directory, value));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (Array.isArray(value)) {
|
|
937
|
+
for (const item of value) recordManifestTargets(item, directory, allowBare);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (value !== null && typeof value === "object") {
|
|
941
|
+
for (const item of Object.values(value)) recordManifestTargets(item, directory, allowBare);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
function recordModuleCandidates(base: string): void {
|
|
945
|
+
const resolvedBase = path.resolve(base);
|
|
946
|
+
if (recordedModuleBases.has(resolvedBase)) return;
|
|
947
|
+
recordedModuleBases.add(resolvedBase);
|
|
948
|
+
for (const candidate of moduleCandidates(resolvedBase)) recordInput(candidate);
|
|
949
|
+
try {
|
|
950
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(resolvedBase, "package.json"), "utf8").replace(/^\uFEFF/, ""));
|
|
951
|
+
recordManifestTargets(manifest.exports, resolvedBase);
|
|
952
|
+
recordManifestTargets(manifest.module, resolvedBase, true);
|
|
953
|
+
recordManifestTargets(manifest.main, resolvedBase, true);
|
|
954
|
+
} catch {}
|
|
955
|
+
}
|
|
956
|
+
function candidateSelected(base: string, resolvedFile: string): boolean {
|
|
957
|
+
for (const candidate of moduleCandidates(base)) {
|
|
958
|
+
try {
|
|
959
|
+
const canonical = fs.realpathSync.native(candidate);
|
|
960
|
+
const relative = path.relative(canonical, resolvedFile);
|
|
961
|
+
if (relative === "" || (fs.statSync(canonical).isDirectory() && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) return true;
|
|
962
|
+
} catch {}
|
|
963
|
+
}
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
function localBases(specifier: string, parentDirectory: string): string[] {
|
|
967
|
+
if (specifier.startsWith("file:")) return [fileURLToPath(specifier)];
|
|
968
|
+
const raw = path.resolve(parentDirectory, specifier);
|
|
969
|
+
const suffixStart = specifier.search(/[?#]/);
|
|
970
|
+
if (suffixStart === -1) return [raw];
|
|
971
|
+
const pathname = specifier.slice(0, suffixStart);
|
|
972
|
+
return pathname === "" ? [raw] : [...new Set([raw, path.resolve(parentDirectory, pathname)])];
|
|
973
|
+
}
|
|
974
|
+
function recordResolutionCandidates(specifier: string, parentURL: string | undefined, resolvedURL: string | undefined): void {
|
|
975
|
+
if (typeof parentURL !== "string" || !parentURL.startsWith("file:")) return;
|
|
976
|
+
const parentDirectory = path.dirname(fileURLToPath(parentURL));
|
|
977
|
+
let resolvedFile: string | undefined;
|
|
978
|
+
try {
|
|
979
|
+
resolvedFile = typeof resolvedURL === "string" && resolvedURL.startsWith("file:")
|
|
980
|
+
? fs.realpathSync.native(fileURLToPath(resolvedURL))
|
|
981
|
+
: undefined;
|
|
982
|
+
} catch {}
|
|
983
|
+
if (specifier.startsWith(".") || path.isAbsolute(specifier) || specifier.startsWith("file:")) {
|
|
984
|
+
try {
|
|
985
|
+
for (const base of localBases(specifier, parentDirectory)) {
|
|
986
|
+
recordPackageManifests(base);
|
|
987
|
+
let exact = false;
|
|
988
|
+
try { exact = resolvedFile === undefined ? fs.statSync(base).isFile() : fs.realpathSync.native(base) === resolvedFile; } catch {}
|
|
989
|
+
if (exact) recordInput(base);
|
|
990
|
+
else recordModuleCandidates(base);
|
|
991
|
+
}
|
|
992
|
+
} catch {}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (isBuiltin(specifier) || specifier.startsWith("#")) return;
|
|
996
|
+
const parts = specifier.split("/");
|
|
997
|
+
const packageParts = parts[0]!.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1);
|
|
998
|
+
if (packageParts.some((part) => part === undefined || part === "")) return;
|
|
999
|
+
const packageName = packageParts.join("/");
|
|
1000
|
+
const subpath = parts.slice(packageParts.length);
|
|
1001
|
+
const searchPaths = createRequire(parentURL).resolve.paths(specifier) ?? [];
|
|
1002
|
+
for (const searchPath of searchPaths) {
|
|
1003
|
+
const packageDirectory = path.join(searchPath, packageName);
|
|
1004
|
+
recordModuleCandidates(packageDirectory);
|
|
1005
|
+
if (subpath.length !== 0) recordModuleCandidates(path.join(packageDirectory, ...subpath));
|
|
1006
|
+
if (resolvedFile !== undefined && candidateSelected(packageDirectory, resolvedFile)) break;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
registerHooks({
|
|
1011
|
+
resolve(specifier, context, nextResolve) {
|
|
1012
|
+
recordResolutionCandidates(specifier, context.parentURL, undefined);
|
|
1013
|
+
const resolved = nextResolve(specifier, context);
|
|
1014
|
+
const url = typeof resolved === "string" ? resolved : resolved?.url;
|
|
1015
|
+
recordResolutionCandidates(specifier, context.parentURL, url);
|
|
1016
|
+
if (typeof url === "string" && url.startsWith("file:")) {
|
|
1017
|
+
recordFile(fileURLToPath(url));
|
|
1018
|
+
}
|
|
1019
|
+
return resolved;
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
451
1022
|
|
|
452
1023
|
declare const process: {
|
|
453
1024
|
exitCode?: number;
|
|
@@ -463,8 +1034,16 @@ declare const process: {
|
|
|
463
1034
|
// left for a trailing handler to settle.
|
|
464
1035
|
(async () => {
|
|
465
1036
|
try {
|
|
1037
|
+
const importedConfig = await import(%s);
|
|
466
1038
|
const value = await resolveConfig(importedConfig);
|
|
467
|
-
|
|
1039
|
+
const serializedValue = toSerializableBanner(value);
|
|
1040
|
+
for (const input of [...inputs]) recordInput(input);
|
|
1041
|
+
process.stdout.write(JSON.stringify({
|
|
1042
|
+
value: serializedValue,
|
|
1043
|
+
hashes: Object.fromEntries(hashes),
|
|
1044
|
+
inputs: [...inputs].sort(),
|
|
1045
|
+
realpaths: Object.fromEntries(realpaths),
|
|
1046
|
+
}));
|
|
468
1047
|
} catch (error) {
|
|
469
1048
|
process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
|
|
470
1049
|
// The stack above is for the reader. This is for the caller: the parent
|
|
@@ -534,6 +1113,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
534
1113
|
// resolving either way.
|
|
535
1114
|
"module": configModuleOption(location),
|
|
536
1115
|
"moduleResolution": "bundler",
|
|
1116
|
+
"jsx": "preserve",
|
|
537
1117
|
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
|
|
538
1118
|
"rewriteRelativeImportExtensions": true,
|
|
539
1119
|
"rootDir": loaderRootDir(outDir),
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
18
18
|
};
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
exports.default = createTtscBanner;
|
|
21
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
21
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
22
24
|
__exportStar(require("./structures/index"), exports);
|
|
23
25
|
/**
|
|
@@ -51,7 +53,11 @@ function createTtscBanner(context) {
|
|
|
51
53
|
`The only accepted key in the tsconfig entry is "configFile" (optional path to the config file).`);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
56
|
+
const configInputs = bannerConfigInputs(context);
|
|
54
57
|
return {
|
|
58
|
+
hostInputHashes: configInputs.hashes,
|
|
59
|
+
hostInputRealpaths: configInputs.realpaths,
|
|
60
|
+
hostInputs: configInputs.inputs,
|
|
55
61
|
name: "@ttsc/banner",
|
|
56
62
|
// Point at the `driver/` directory one level above `lib/` in the
|
|
57
63
|
// installed package tree (where the Go sources live). `context.dirname`
|
|
@@ -61,4 +67,83 @@ function createTtscBanner(context) {
|
|
|
61
67
|
stage: "transform",
|
|
62
68
|
};
|
|
63
69
|
}
|
|
70
|
+
const BANNER_CONFIG_FILENAMES = [
|
|
71
|
+
"banner.config.json",
|
|
72
|
+
"banner.config.js",
|
|
73
|
+
"banner.config.cjs",
|
|
74
|
+
"banner.config.mjs",
|
|
75
|
+
"banner.config.ts",
|
|
76
|
+
"banner.config.cts",
|
|
77
|
+
"banner.config.mts",
|
|
78
|
+
];
|
|
79
|
+
/** Mirror native config resolution while retaining missing priority probes. */
|
|
80
|
+
function bannerConfigInputs(context) {
|
|
81
|
+
const configFile = context.plugin.configFile;
|
|
82
|
+
const base = node_path_1.default.resolve(context.pluginConfigDir ?? node_path_1.default.dirname(context.tsconfig));
|
|
83
|
+
if (typeof configFile === "string" && configFile.trim() !== "") {
|
|
84
|
+
const file = node_path_1.default.isAbsolute(configFile)
|
|
85
|
+
? node_path_1.default.resolve(configFile)
|
|
86
|
+
: node_path_1.default.resolve(base, configFile);
|
|
87
|
+
return {
|
|
88
|
+
hashes: { [file]: hostInputHash(file) },
|
|
89
|
+
inputs: [file],
|
|
90
|
+
realpaths: { [file]: hostInputRealpath(file) },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return configDiscoveryInputs(base, BANNER_CONFIG_FILENAMES);
|
|
94
|
+
}
|
|
95
|
+
function configDiscoveryInputs(base, names) {
|
|
96
|
+
const inputs = [];
|
|
97
|
+
const hashes = {};
|
|
98
|
+
const realpaths = {};
|
|
99
|
+
for (let directory = base;; directory = node_path_1.default.dirname(directory)) {
|
|
100
|
+
const candidates = names.map((name) => node_path_1.default.join(directory, name));
|
|
101
|
+
inputs.push(...candidates);
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
hashes[candidate] = hostInputHash(candidate);
|
|
104
|
+
realpaths[candidate] = hostInputRealpath(candidate);
|
|
105
|
+
}
|
|
106
|
+
if (candidates.some(configCandidateExists))
|
|
107
|
+
break;
|
|
108
|
+
const parent = node_path_1.default.dirname(directory);
|
|
109
|
+
if (parent === directory)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
return { hashes, inputs, realpaths };
|
|
113
|
+
}
|
|
114
|
+
function hostInputRealpath(file) {
|
|
115
|
+
try {
|
|
116
|
+
return node_fs_1.default.realpathSync.native(file);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Hash the exact candidate state observed before discovery selects a file. */
|
|
123
|
+
function hostInputHash(file) {
|
|
124
|
+
try {
|
|
125
|
+
if (node_fs_1.default.statSync(file).isDirectory()) {
|
|
126
|
+
return node_crypto_1.default
|
|
127
|
+
.createHash("sha256")
|
|
128
|
+
.update("ttsc:host-input:directory\0")
|
|
129
|
+
.digest("hex");
|
|
130
|
+
}
|
|
131
|
+
return node_crypto_1.default
|
|
132
|
+
.createHash("sha256")
|
|
133
|
+
.update(node_fs_1.default.readFileSync(file))
|
|
134
|
+
.digest("hex");
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Match the native discovery rule: a directory is never a config file. */
|
|
141
|
+
function configCandidateExists(file) {
|
|
142
|
+
try {
|
|
143
|
+
return !node_fs_1.default.statSync(file).isDirectory();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
64
149
|
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,0DAA6B;AAI7B,qDAAmC;
|
|
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.27.0",
|
|
4
4
|
"description": "First-party ttsc plugin that adds package-documentation JSDoc banners during emit.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@types/node": "^25.3.0",
|
|
36
36
|
"rimraf": "^6.1.2",
|
|
37
37
|
"typescript": "^7.0.2",
|
|
38
|
-
"ttsc": "0.
|
|
38
|
+
"ttsc": "0.27.0"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
|
|
3
5
|
import type { ITtscBannerPluginConfig } from "./structures";
|
|
@@ -12,6 +14,12 @@ export * from "./structures/index";
|
|
|
12
14
|
* executable sidecar or linked native source.
|
|
13
15
|
*/
|
|
14
16
|
type TtscPluginDescriptor = {
|
|
17
|
+
/** Universal config-discovery inputs consumed by the native transform. */
|
|
18
|
+
hostInputs?: string[];
|
|
19
|
+
/** Evaluation-time fingerprints paired with {@link hostInputs}. */
|
|
20
|
+
hostInputHashes?: Record<string, string | null>;
|
|
21
|
+
/** Evaluation-time physical targets paired with {@link hostInputs}. */
|
|
22
|
+
hostInputRealpaths?: Record<string, string | null>;
|
|
15
23
|
/** Human-readable plugin name used in logs and error messages. */
|
|
16
24
|
name: string;
|
|
17
25
|
/** Absolute path to the Go source directory for this plugin. */
|
|
@@ -45,6 +53,8 @@ type TtscPluginFactoryContext<TConfig> = {
|
|
|
45
53
|
* replacement for `__filename`.
|
|
46
54
|
*/
|
|
47
55
|
filename: string;
|
|
56
|
+
/** Host-declared anchor for implicit plugin config discovery. */
|
|
57
|
+
pluginConfigDir?: string;
|
|
48
58
|
/** The raw plugin entry from `compilerOptions.plugins[]`. */
|
|
49
59
|
plugin: TConfig;
|
|
50
60
|
/** Absolute path to the project root (directory containing tsconfig). */
|
|
@@ -90,7 +100,11 @@ export default function createTtscBanner(
|
|
|
90
100
|
}
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
const configInputs = bannerConfigInputs(context);
|
|
93
104
|
return {
|
|
105
|
+
hostInputHashes: configInputs.hashes,
|
|
106
|
+
hostInputRealpaths: configInputs.realpaths,
|
|
107
|
+
hostInputs: configInputs.inputs,
|
|
94
108
|
name: "@ttsc/banner",
|
|
95
109
|
// Point at the `driver/` directory one level above `lib/` in the
|
|
96
110
|
// installed package tree (where the Go sources live). `context.dirname`
|
|
@@ -100,3 +114,98 @@ export default function createTtscBanner(
|
|
|
100
114
|
stage: "transform",
|
|
101
115
|
};
|
|
102
116
|
}
|
|
117
|
+
|
|
118
|
+
const BANNER_CONFIG_FILENAMES = [
|
|
119
|
+
"banner.config.json",
|
|
120
|
+
"banner.config.js",
|
|
121
|
+
"banner.config.cjs",
|
|
122
|
+
"banner.config.mjs",
|
|
123
|
+
"banner.config.ts",
|
|
124
|
+
"banner.config.cts",
|
|
125
|
+
"banner.config.mts",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Mirror native config resolution while retaining missing priority probes. */
|
|
129
|
+
function bannerConfigInputs(
|
|
130
|
+
context: TtscPluginFactoryContext<ITtscBannerPluginConfig>,
|
|
131
|
+
): {
|
|
132
|
+
hashes: Record<string, string | null>;
|
|
133
|
+
inputs: string[];
|
|
134
|
+
realpaths: Record<string, string | null>;
|
|
135
|
+
} {
|
|
136
|
+
const configFile = (context.plugin as { configFile?: unknown }).configFile;
|
|
137
|
+
const base = path.resolve(
|
|
138
|
+
context.pluginConfigDir ?? path.dirname(context.tsconfig),
|
|
139
|
+
);
|
|
140
|
+
if (typeof configFile === "string" && configFile.trim() !== "") {
|
|
141
|
+
const file = path.isAbsolute(configFile)
|
|
142
|
+
? path.resolve(configFile)
|
|
143
|
+
: path.resolve(base, configFile);
|
|
144
|
+
return {
|
|
145
|
+
hashes: { [file]: hostInputHash(file) },
|
|
146
|
+
inputs: [file],
|
|
147
|
+
realpaths: { [file]: hostInputRealpath(file) },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return configDiscoveryInputs(base, BANNER_CONFIG_FILENAMES);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function configDiscoveryInputs(
|
|
154
|
+
base: string,
|
|
155
|
+
names: readonly string[],
|
|
156
|
+
): {
|
|
157
|
+
hashes: Record<string, string | null>;
|
|
158
|
+
inputs: string[];
|
|
159
|
+
realpaths: Record<string, string | null>;
|
|
160
|
+
} {
|
|
161
|
+
const inputs: string[] = [];
|
|
162
|
+
const hashes: Record<string, string | null> = {};
|
|
163
|
+
const realpaths: Record<string, string | null> = {};
|
|
164
|
+
for (let directory = base; ; directory = path.dirname(directory)) {
|
|
165
|
+
const candidates = names.map((name) => path.join(directory, name));
|
|
166
|
+
inputs.push(...candidates);
|
|
167
|
+
for (const candidate of candidates) {
|
|
168
|
+
hashes[candidate] = hostInputHash(candidate);
|
|
169
|
+
realpaths[candidate] = hostInputRealpath(candidate);
|
|
170
|
+
}
|
|
171
|
+
if (candidates.some(configCandidateExists)) break;
|
|
172
|
+
const parent = path.dirname(directory);
|
|
173
|
+
if (parent === directory) break;
|
|
174
|
+
}
|
|
175
|
+
return { hashes, inputs, realpaths };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function hostInputRealpath(file: string): string | null {
|
|
179
|
+
try {
|
|
180
|
+
return fs.realpathSync.native(file);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Hash the exact candidate state observed before discovery selects a file. */
|
|
187
|
+
function hostInputHash(file: string): string | null {
|
|
188
|
+
try {
|
|
189
|
+
if (fs.statSync(file).isDirectory()) {
|
|
190
|
+
return crypto
|
|
191
|
+
.createHash("sha256")
|
|
192
|
+
.update("ttsc:host-input:directory\0")
|
|
193
|
+
.digest("hex");
|
|
194
|
+
}
|
|
195
|
+
return crypto
|
|
196
|
+
.createHash("sha256")
|
|
197
|
+
.update(fs.readFileSync(file))
|
|
198
|
+
.digest("hex");
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Match the native discovery rule: a directory is never a config file. */
|
|
205
|
+
function configCandidateExists(file: string): boolean {
|
|
206
|
+
try {
|
|
207
|
+
return !fs.statSync(file).isDirectory();
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|