@ttsc/banner 0.26.1 → 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 +900 -69
- 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
|
}
|
|
@@ -92,16 +101,34 @@ func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error
|
|
|
92
101
|
return b.String(), nil
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
// sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
|
|
105
|
+
// by replacing "*/" with "* /" so the generated block comment stays valid.
|
|
106
|
+
func sanitizeJSDocLine(line string) string {
|
|
107
|
+
return strings.ReplaceAll(line, "*/", "* /")
|
|
108
|
+
}
|
|
109
|
+
|
|
95
110
|
// resolveBannerText extracts the banner text from the plugin config.
|
|
96
111
|
// The config entry is validated first: only the "configFile" key (plus
|
|
97
112
|
// framework keys) is accepted. When "configFile" is present its value is
|
|
98
113
|
// resolved to an absolute path and loaded. When absent the upward-walk
|
|
99
114
|
// discovery is used. Returns an error when the config is invalid or when
|
|
100
115
|
// no banner text can be found.
|
|
116
|
+
//
|
|
117
|
+
// The discovery base directory doubles as the resolution root the config
|
|
118
|
+
// loader anchors its toolchain lookup on; see configToolAnchors.
|
|
101
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) {
|
|
102
128
|
if err := validateBannerConfig(config); err != nil {
|
|
103
129
|
return "", err
|
|
104
130
|
}
|
|
131
|
+
resolutionRoot := tsconfigBaseDir(cwd, tsconfigPath)
|
|
105
132
|
|
|
106
133
|
if rawConfigFile, ok := config["configFile"]; ok {
|
|
107
134
|
configFile, ok := rawConfigFile.(string)
|
|
@@ -109,11 +136,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
|
|
|
109
136
|
return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
|
|
110
137
|
}
|
|
111
138
|
location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
|
|
112
|
-
|
|
139
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
113
140
|
if err != nil {
|
|
114
141
|
return "", err
|
|
115
142
|
}
|
|
116
|
-
|
|
143
|
+
reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
|
|
144
|
+
text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
|
|
117
145
|
if err != nil {
|
|
118
146
|
return "", err
|
|
119
147
|
}
|
|
@@ -130,11 +158,12 @@ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string,
|
|
|
130
158
|
if location == "" {
|
|
131
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")
|
|
132
160
|
}
|
|
133
|
-
|
|
161
|
+
loaded, err := loadBannerConfigFileWithInputs(location, resolutionRoot)
|
|
134
162
|
if err != nil {
|
|
135
163
|
return "", err
|
|
136
164
|
}
|
|
137
|
-
|
|
165
|
+
reportBannerConfigInputs(loaded.inputs, loaded.hashes, loaded.realpaths, reporter, hashReporter, realpathReporter)
|
|
166
|
+
text, ok, err := bannerTextFromConfigValue(loaded.value, filepath.Base(location))
|
|
138
167
|
if err != nil {
|
|
139
168
|
return "", err
|
|
140
169
|
}
|
|
@@ -238,18 +267,123 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
|
|
|
238
267
|
// must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}; JS/CJS/MJS variants
|
|
239
268
|
// run under Node, TypeScript variants compile and run via ttsx in a temp
|
|
240
269
|
// directory, and JSON files are parsed natively.
|
|
241
|
-
|
|
270
|
+
//
|
|
271
|
+
// resolutionRoot is the project directory the TypeScript branch anchors its
|
|
272
|
+
// toolchain resolution on when the config file's own ancestry answers nothing;
|
|
273
|
+
// see configToolAnchors. The JSON and JS branches spawn no ttsx and ignore it.
|
|
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) {
|
|
242
287
|
if !isBannerConfigFileName(filepath.Base(location)) {
|
|
243
|
-
return
|
|
288
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
|
|
244
289
|
}
|
|
245
290
|
ext := strings.ToLower(filepath.Ext(location))
|
|
246
291
|
switch ext {
|
|
247
292
|
case ".json":
|
|
248
|
-
|
|
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
|
|
249
300
|
case ".js", ".cjs", ".mjs":
|
|
250
|
-
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
|
|
251
386
|
}
|
|
252
|
-
return loadBannerTypeScriptConfigFile(location)
|
|
253
387
|
}
|
|
254
388
|
|
|
255
389
|
// isBannerConfigFileName reports whether name is an allowed banner config file name.
|
|
@@ -276,6 +410,10 @@ func loadBannerJSONConfigFile(location string) (any, error) {
|
|
|
276
410
|
if err != nil {
|
|
277
411
|
return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
|
|
278
412
|
}
|
|
413
|
+
return parseBannerJSONConfigFile(location, body)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func parseBannerJSONConfigFile(location string, body []byte) (any, error) {
|
|
279
417
|
// Strip a leading UTF-8 BOM so files saved by Windows editors round
|
|
280
418
|
// trip through json.Unmarshal without an opaque "invalid character" failure.
|
|
281
419
|
body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
|
|
@@ -290,8 +428,207 @@ func loadBannerJSONConfigFile(location string) (any, error) {
|
|
|
290
428
|
// running a small Node.js loader script that dynamic-imports the file and
|
|
291
429
|
// serializes its exported value to stdout as JSON.
|
|
292
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) {
|
|
293
436
|
const script = `
|
|
294
|
-
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
|
+
});
|
|
295
632
|
|
|
296
633
|
(async () => {
|
|
297
634
|
const mod = await import(pathToFileURL(process.argv[1]).href);
|
|
@@ -307,15 +644,11 @@ const { pathToFileURL } = require("node:url");
|
|
|
307
644
|
break;
|
|
308
645
|
}
|
|
309
646
|
const value = typeof current === "function" ? await current() : current;
|
|
310
|
-
|
|
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) }));
|
|
311
650
|
})().catch((error) => {
|
|
312
651
|
process.stderr.write(error && error.stack ? error.stack : String(error));
|
|
313
|
-
// The stack above is for the reader. This is for the caller: the parent reads
|
|
314
|
-
// stdout as the payload channel either way, so a failure reason travels as
|
|
315
|
-
// data rather than as text scraped back out of a captured stream. The exit
|
|
316
|
-
// code is set before the write so a callback that never fires still fails the
|
|
317
|
-
// load, and the write's completion is what triggers the exit, because
|
|
318
|
-
// process.exit abandons a pending pipe write.
|
|
319
652
|
process.exitCode = 1;
|
|
320
653
|
process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
|
|
321
654
|
});
|
|
@@ -333,7 +666,13 @@ function toSerializableBanner(value) {
|
|
|
333
666
|
}
|
|
334
667
|
ctx, cancel := context.WithCancel(context.Background())
|
|
335
668
|
defer cancel()
|
|
336
|
-
|
|
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)
|
|
337
676
|
cmd.Env = nodeConfigLoaderEnv(location)
|
|
338
677
|
// The child's stderr is human output and goes straight to this process's
|
|
339
678
|
// stderr as it is written. Collecting it only to replay it afterwards is what
|
|
@@ -346,15 +685,46 @@ function toSerializableBanner(value) {
|
|
|
346
685
|
// What it could not put there is a reason a caller can act on, so that
|
|
347
686
|
// arrives through the payload channel instead.
|
|
348
687
|
if reason := loaderFailureReason(output); reason != "" {
|
|
349
|
-
return
|
|
688
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
|
|
350
689
|
}
|
|
351
|
-
return
|
|
690
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
|
|
352
691
|
}
|
|
353
|
-
|
|
354
|
-
if err
|
|
355
|
-
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)
|
|
356
695
|
}
|
|
357
|
-
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
|
|
358
728
|
}
|
|
359
729
|
|
|
360
730
|
// loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
|
|
@@ -362,29 +732,38 @@ function toSerializableBanner(value) {
|
|
|
362
732
|
// is created so the config file can import its own dependencies. The ttsx
|
|
363
733
|
// build runs with `--no-plugins` so evaluating the config never triggers the
|
|
364
734
|
// host project's transform/check plugins against the loader tsconfig.
|
|
365
|
-
|
|
735
|
+
//
|
|
736
|
+
// Both tools this spawns — the launcher and the compiler handed to it — are
|
|
737
|
+
// resolved from the project rather than from the process environment alone;
|
|
738
|
+
// see configToolAnchors.
|
|
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) {
|
|
366
745
|
tempDir, err := os.MkdirTemp(loaderTempBase(location, os.TempDir()), "ttsc-banner-config-")
|
|
367
746
|
if err != nil {
|
|
368
|
-
return
|
|
747
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
|
|
369
748
|
}
|
|
370
749
|
defer os.RemoveAll(tempDir)
|
|
371
750
|
|
|
372
751
|
if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
|
|
373
|
-
return
|
|
752
|
+
return bannerLoadedConfig{}, err
|
|
374
753
|
}
|
|
375
754
|
|
|
376
755
|
loader := filepath.Join(tempDir, "loader.mts")
|
|
377
756
|
tsconfig := filepath.Join(tempDir, "tsconfig.json")
|
|
378
757
|
importSpecifier, err := relativeImportSpecifier(tempDir, location)
|
|
379
758
|
if err != nil {
|
|
380
|
-
return
|
|
759
|
+
return bannerLoadedConfig{}, err
|
|
381
760
|
}
|
|
382
761
|
importLiteral, _ := json.Marshal(importSpecifier)
|
|
383
762
|
if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
|
|
384
|
-
return
|
|
763
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
|
|
385
764
|
}
|
|
386
765
|
if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
|
|
387
|
-
return
|
|
766
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
|
|
388
767
|
}
|
|
389
768
|
|
|
390
769
|
args := []string{
|
|
@@ -393,14 +772,15 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
|
|
|
393
772
|
"--cache-dir", filepath.Join(tempDir, "cache"),
|
|
394
773
|
"--no-plugins",
|
|
395
774
|
}
|
|
396
|
-
|
|
775
|
+
anchors := configToolAnchors(location, resolutionRoot)
|
|
776
|
+
if tsgo := resolveConfigTsgo(anchors); tsgo != "" {
|
|
397
777
|
args = append(args, "--binary", tsgo)
|
|
398
778
|
}
|
|
399
779
|
args = append(args, loader)
|
|
400
780
|
|
|
401
781
|
ctx, cancel := context.WithCancel(context.Background())
|
|
402
782
|
defer cancel()
|
|
403
|
-
cmd := ttsxCommandContext(ctx, args...)
|
|
783
|
+
cmd := ttsxCommandContext(ctx, anchors, args...)
|
|
404
784
|
cmd.Env = nodeConfigLoaderEnv(location)
|
|
405
785
|
// The child's stderr is human output and goes straight to this process's
|
|
406
786
|
// stderr as it is written. Collecting it only to replay it afterwards is what
|
|
@@ -413,36 +793,232 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
|
|
|
413
793
|
// What it could not put there is a reason a caller can act on, so that
|
|
414
794
|
// arrives through the payload channel instead.
|
|
415
795
|
if reason := loaderFailureReason(output); reason != "" {
|
|
416
|
-
return
|
|
796
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
|
|
417
797
|
}
|
|
418
|
-
return
|
|
798
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
|
|
419
799
|
}
|
|
420
|
-
|
|
421
|
-
if err := json.Unmarshal(output, &out); err != nil {
|
|
422
|
-
return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
|
|
423
|
-
}
|
|
424
|
-
return out, nil
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
|
|
428
|
-
// import specifier for location relative to fromDir.
|
|
429
|
-
func relativeImportSpecifier(fromDir, location string) (string, error) {
|
|
430
|
-
relative, err := filepath.Rel(fromDir, location)
|
|
800
|
+
loaded, err := decodeBannerConfigLoaderOutput(output)
|
|
431
801
|
if err != nil {
|
|
432
|
-
return
|
|
802
|
+
return bannerLoadedConfig{}, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
|
|
433
803
|
}
|
|
434
|
-
|
|
435
|
-
if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
|
|
436
|
-
return relative, nil
|
|
437
|
-
}
|
|
438
|
-
return "./" + relative, nil
|
|
804
|
+
return loaded, nil
|
|
439
805
|
}
|
|
440
806
|
|
|
441
807
|
// bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
|
|
442
808
|
// module that imports the banner config file specified by importLiteral (a
|
|
443
809
|
// JSON-encoded import specifier) and writes the serialized banner value to stdout.
|
|
444
810
|
func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
|
|
445
|
-
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
|
+
});
|
|
446
1022
|
|
|
447
1023
|
declare const process: {
|
|
448
1024
|
exitCode?: number;
|
|
@@ -458,8 +1034,16 @@ declare const process: {
|
|
|
458
1034
|
// left for a trailing handler to settle.
|
|
459
1035
|
(async () => {
|
|
460
1036
|
try {
|
|
1037
|
+
const importedConfig = await import(%s);
|
|
461
1038
|
const value = await resolveConfig(importedConfig);
|
|
462
|
-
|
|
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
|
+
}));
|
|
463
1047
|
} catch (error) {
|
|
464
1048
|
process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
|
|
465
1049
|
// The stack above is for the reader. This is for the caller: the parent
|
|
@@ -529,6 +1113,7 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
529
1113
|
// resolving either way.
|
|
530
1114
|
"module": configModuleOption(location),
|
|
531
1115
|
"moduleResolution": "bundler",
|
|
1116
|
+
"jsx": "preserve",
|
|
532
1117
|
"outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
|
|
533
1118
|
"rewriteRelativeImportExtensions": true,
|
|
534
1119
|
"rootDir": loaderRootDir(outDir),
|
|
@@ -551,6 +1136,23 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
|
|
|
551
1136
|
return string(body)
|
|
552
1137
|
}
|
|
553
1138
|
|
|
1139
|
+
// ttsc:config-loader-shared begin
|
|
1140
|
+
//
|
|
1141
|
+
// One policy in three Go copies: everything between these markers is
|
|
1142
|
+
// duplicated verbatim in packages/lint/linthost/config.go,
|
|
1143
|
+
// packages/banner/driver/banner.go and packages/strip/driver/config.go. #1169
|
|
1144
|
+
// decided against extracting it — the only home the three modules could share
|
|
1145
|
+
// is the public `packages/ttsc/driver` seam, and packages/lint's go.mod
|
|
1146
|
+
// deliberately requires no in-tree ttsc module — and replaced the checklist
|
|
1147
|
+
// with a gate: `scripts/ci/config-loader-copies.cjs` compares every function
|
|
1148
|
+
// between these markers across all three copies on every pull request, so
|
|
1149
|
+
// editing one and not the others fails by name. That file's header carries the
|
|
1150
|
+
// full decision and the rules for changing this block.
|
|
1151
|
+
//
|
|
1152
|
+
// The code between the markers must stay identical. Comments may differ, the
|
|
1153
|
+
// `@ttsc/<pkg>:` error prefix may differ, and @ttsc/strip spells each name with
|
|
1154
|
+
// a `strip` prefix. Anything package-specific belongs outside the markers.
|
|
1155
|
+
|
|
554
1156
|
// configModuleOption returns the loader tsconfig's "module" for a config file:
|
|
555
1157
|
// the module kind Node itself would give that file.
|
|
556
1158
|
//
|
|
@@ -673,21 +1275,240 @@ func resolveDirLink(dir string) string {
|
|
|
673
1275
|
return dir
|
|
674
1276
|
}
|
|
675
1277
|
|
|
1278
|
+
// realpathIfPossible resolves location through its symlinks, and returns it
|
|
1279
|
+
// unchanged when it cannot be evaluated (a path that does not exist, or an
|
|
1280
|
+
// NTFS junction filepath.EvalSymlinks refuses to traverse).
|
|
1281
|
+
func realpathIfPossible(location string) string {
|
|
1282
|
+
real, err := filepath.EvalSymlinks(location)
|
|
1283
|
+
if err != nil {
|
|
1284
|
+
return location
|
|
1285
|
+
}
|
|
1286
|
+
return real
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Both tools the TypeScript config evaluator needs — the `ttsx` launcher it
|
|
1290
|
+
// spawns and the native compiler it hands that launcher — are resolved from
|
|
1291
|
+
// the project being compiled, with an explicit environment variable winning
|
|
1292
|
+
// and a last resort that invents no path.
|
|
1293
|
+
//
|
|
1294
|
+
// The three Go copies are held identical by the gate named at the top of this
|
|
1295
|
+
// block. The JS original — `resolveConfigTsgo` / `resolveTtsxLauncher` in
|
|
1296
|
+
// packages/lint/src/index.ts — is a fourth copy in another language that no Go
|
|
1297
|
+
// gate can reach; what it owes is that both policies stay describable in one
|
|
1298
|
+
// sentence.
|
|
1299
|
+
//
|
|
1300
|
+
// The environment alone is the wrong place to ask. `ttsx` exports
|
|
1301
|
+
// TTSC_TSGO_BINARY and TTSC_TTSX_BINARY to its own descendants, so a host
|
|
1302
|
+
// launched under `ttsx` inherited both and a host launched any other way
|
|
1303
|
+
// inherited neither. The shipped `ttscserver` binary invoked with its
|
|
1304
|
+
// documented `--tsgo <path>` flag keeps that path in a local and exports
|
|
1305
|
+
// nothing, and an embedder of the driver package exports nothing either. For
|
|
1306
|
+
// those the evaluator spawned a bare `ttsx` that only a global install puts on
|
|
1307
|
+
// PATH, and, past that, a compiler-less child that aborted with
|
|
1308
|
+
// `ttsc: typescript is required` before a line of the config was read.
|
|
1309
|
+
//
|
|
1310
|
+
// configToolAnchors lists the file paths those resolutions walk upward from,
|
|
1311
|
+
// in order: the config file being evaluated, then the resolution root's
|
|
1312
|
+
// manifest. The config comes first because it is the file whose own
|
|
1313
|
+
// installation decides which toolchain the config's imports were written
|
|
1314
|
+
// against; the resolution root answers for a config that lives outside the
|
|
1315
|
+
// project tree (a `configFile` pointed at a shared package), and for one
|
|
1316
|
+
// discovered above a workspace that installs its own toolchain.
|
|
1317
|
+
func configToolAnchors(configPath, resolutionRoot string) []string {
|
|
1318
|
+
anchors := make([]string, 0, 2)
|
|
1319
|
+
if strings.TrimSpace(configPath) != "" {
|
|
1320
|
+
anchors = append(anchors, configPath)
|
|
1321
|
+
}
|
|
1322
|
+
if strings.TrimSpace(resolutionRoot) != "" {
|
|
1323
|
+
anchors = append(anchors, filepath.Join(resolutionRoot, "package.json"))
|
|
1324
|
+
}
|
|
1325
|
+
return anchors
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// resolveConfigTsgo returns the native TypeScript compiler the evaluator hands
|
|
1329
|
+
// its ttsx child through `--binary`, or "" to leave the child resolving for
|
|
1330
|
+
// itself.
|
|
1331
|
+
//
|
|
1332
|
+
// The child runs with `--cwd <ephemeral loader dir>`, so it cannot discover
|
|
1333
|
+
// `typescript` the way an ordinary invocation does: linkNearestNodeModules is
|
|
1334
|
+
// the only thing that puts the project's modules within its reach, and it links
|
|
1335
|
+
// nothing when the config's ancestry carries no node_modules. An explicit
|
|
1336
|
+
// TTSC_TSGO_BINARY still wins, so an embedder that pins a compiler keeps
|
|
1337
|
+
// pinning it. "" is the unchanged last resort: a project that cannot answer
|
|
1338
|
+
// here could not answer inside the child either, and the child's own diagnostic
|
|
1339
|
+
// is the one that names the missing package.
|
|
1340
|
+
func resolveConfigTsgo(anchors []string) string {
|
|
1341
|
+
if explicit := strings.TrimSpace(os.Getenv("TTSC_TSGO_BINARY")); explicit != "" {
|
|
1342
|
+
return explicit
|
|
1343
|
+
}
|
|
1344
|
+
for _, anchor := range anchors {
|
|
1345
|
+
if binary := tsgoBinaryFrom(anchor); binary != "" {
|
|
1346
|
+
return binary
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return ""
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// tsgoBinaryFrom returns the platform compiler executable of the `typescript`
|
|
1353
|
+
// install `anchor` can see, or "" when this anchor reaches neither the package
|
|
1354
|
+
// nor its platform dependency.
|
|
1355
|
+
//
|
|
1356
|
+
// Mirrors resolveTsgo.ts so the Go plugin and the JS launcher name one file:
|
|
1357
|
+
// the `typescript` manifest, then `@typescript/typescript-<platform>-<arch>`
|
|
1358
|
+
// resolved from that manifest, then `lib/tsc` inside it.
|
|
1359
|
+
//
|
|
1360
|
+
// The install is chased to its real directory before the second hop, because
|
|
1361
|
+
// Node resolves a module's own dependencies from its real location. pnpm keeps
|
|
1362
|
+
// the real `typescript` directory in its content-addressed store with the
|
|
1363
|
+
// platform package beside it and leaves a link in the project's node_modules,
|
|
1364
|
+
// so a walk that started at the link would climb straight past the platform
|
|
1365
|
+
// package. NTFS junctions defeat filepath.EvalSymlinks, so the link component
|
|
1366
|
+
// is chased by hand first, the same order loaderTempBase uses.
|
|
1367
|
+
func tsgoBinaryFrom(anchor string) string {
|
|
1368
|
+
manifest := nodePackageManifestFrom(anchor, "typescript")
|
|
1369
|
+
if manifest == "" {
|
|
1370
|
+
return ""
|
|
1371
|
+
}
|
|
1372
|
+
packageDir := realpathIfPossible(resolveDirLink(filepath.Dir(manifest)))
|
|
1373
|
+
platform, arch := nodePlatformPair()
|
|
1374
|
+
platformManifest := nodePackageManifestFrom(
|
|
1375
|
+
filepath.Join(packageDir, "package.json"),
|
|
1376
|
+
"@typescript/typescript-"+platform+"-"+arch,
|
|
1377
|
+
)
|
|
1378
|
+
if platformManifest == "" {
|
|
1379
|
+
return ""
|
|
1380
|
+
}
|
|
1381
|
+
name := "tsc"
|
|
1382
|
+
if runtime.GOOS == "windows" {
|
|
1383
|
+
name = "tsc.exe"
|
|
1384
|
+
}
|
|
1385
|
+
binary := filepath.Join(filepath.Dir(platformManifest), "lib", name)
|
|
1386
|
+
if stat, err := os.Stat(binary); err != nil || stat.IsDir() {
|
|
1387
|
+
return ""
|
|
1388
|
+
}
|
|
1389
|
+
return binary
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// resolveTtsxLauncher returns the launcher ttsxCommandContext spawns.
|
|
1393
|
+
//
|
|
1394
|
+
// An explicit TTSC_TTSX_BINARY wins. Otherwise the launcher is derived from the
|
|
1395
|
+
// `ttsc` installation one of the anchors can see, because a bare command name
|
|
1396
|
+
// only works when a bin link happens to be on PATH — which it is for a global
|
|
1397
|
+
// install and is not for the ordinary project-local one. The bare `"ttsx"` name
|
|
1398
|
+
// remains the unchanged last resort for an installation no anchor reaches.
|
|
1399
|
+
func resolveTtsxLauncher(anchors []string) string {
|
|
1400
|
+
if explicit := strings.TrimSpace(os.Getenv("TTSC_TTSX_BINARY")); explicit != "" {
|
|
1401
|
+
return explicit
|
|
1402
|
+
}
|
|
1403
|
+
for _, anchor := range anchors {
|
|
1404
|
+
if launcher := ttsxLauncherFrom(anchor); launcher != "" {
|
|
1405
|
+
return launcher
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return "ttsx"
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// ttsxLauncherFrom returns `lib/launcher/ttsx.js` of the `ttsc` install
|
|
1412
|
+
// `anchor` can see, or "" when this anchor reaches no such install. Only the
|
|
1413
|
+
// manifest is an exported subpath, so the launcher is derived from where the
|
|
1414
|
+
// manifest resolved rather than requested as a subpath of its own.
|
|
1415
|
+
func ttsxLauncherFrom(anchor string) string {
|
|
1416
|
+
manifest := nodePackageManifestFrom(anchor, "ttsc")
|
|
1417
|
+
if manifest == "" {
|
|
1418
|
+
return ""
|
|
1419
|
+
}
|
|
1420
|
+
launcher := filepath.Join(filepath.Dir(manifest), "lib", "launcher", "ttsx.js")
|
|
1421
|
+
if stat, err := os.Stat(launcher); err != nil || stat.IsDir() {
|
|
1422
|
+
return ""
|
|
1423
|
+
}
|
|
1424
|
+
return launcher
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// nodePackageManifestFrom resolves `<pkg>/package.json` the way Node's
|
|
1428
|
+
// require.resolve does from the FILE `anchor`: walk upward from the anchor's
|
|
1429
|
+
// directory and return the first `<dir>/node_modules/<pkg>/package.json` that
|
|
1430
|
+
// exists. The anchor is treated as a file path, so its own directory is the
|
|
1431
|
+
// first candidate's parent, and it need not exist — Node derives the search
|
|
1432
|
+
// paths from the string alone.
|
|
1433
|
+
//
|
|
1434
|
+
// A directory already named `node_modules` contributes no candidate of its own,
|
|
1435
|
+
// matching Module._nodeModulePaths, so nothing ever resolves through
|
|
1436
|
+
// `node_modules/node_modules`.
|
|
1437
|
+
//
|
|
1438
|
+
// A relative anchor is resolved against the process directory before the walk,
|
|
1439
|
+
// again matching Node. Walking a relative path instead would terminate at "."
|
|
1440
|
+
// after one step and silently answer nothing for a config named relatively.
|
|
1441
|
+
func nodePackageManifestFrom(anchor, pkg string) string {
|
|
1442
|
+
if strings.TrimSpace(anchor) == "" || pkg == "" {
|
|
1443
|
+
return ""
|
|
1444
|
+
}
|
|
1445
|
+
if absolute, err := filepath.Abs(anchor); err == nil {
|
|
1446
|
+
anchor = absolute
|
|
1447
|
+
}
|
|
1448
|
+
dir := filepath.Dir(filepath.Clean(anchor))
|
|
1449
|
+
for {
|
|
1450
|
+
if filepath.Base(dir) != "node_modules" {
|
|
1451
|
+
candidate := filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")
|
|
1452
|
+
if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
|
|
1453
|
+
return candidate
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
parent := filepath.Dir(dir)
|
|
1457
|
+
if parent == dir {
|
|
1458
|
+
return ""
|
|
1459
|
+
}
|
|
1460
|
+
dir = parent
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// nodePlatformPair is nodePlatformPairFor applied to this build's own target.
|
|
1465
|
+
func nodePlatformPair() (string, string) {
|
|
1466
|
+
return nodePlatformPairFor(runtime.GOOS, runtime.GOARCH)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// nodePlatformPairFor maps a Go build target onto the `process.platform` and
|
|
1470
|
+
// `process.arch` pair npm spells a platform package with, so the package name
|
|
1471
|
+
// this plugin resolves is the same one the JS launcher resolves.
|
|
1472
|
+
//
|
|
1473
|
+
// Only the members whose two vocabularies disagree are mapped. Every other
|
|
1474
|
+
// value is identical on both sides and passes through, which keeps a target
|
|
1475
|
+
// neither side publishes yet resolvable rather than silently wrong, and keeps
|
|
1476
|
+
// this from becoming a list that has to grow with every new port.
|
|
1477
|
+
func nodePlatformPairFor(goos, goarch string) (string, string) {
|
|
1478
|
+
platform := goos
|
|
1479
|
+
switch platform {
|
|
1480
|
+
case "windows":
|
|
1481
|
+
platform = "win32"
|
|
1482
|
+
case "solaris":
|
|
1483
|
+
platform = "sunos"
|
|
1484
|
+
}
|
|
1485
|
+
arch := goarch
|
|
1486
|
+
switch arch {
|
|
1487
|
+
case "amd64":
|
|
1488
|
+
arch = "x64"
|
|
1489
|
+
case "386":
|
|
1490
|
+
arch = "ia32"
|
|
1491
|
+
case "ppc64le":
|
|
1492
|
+
arch = "ppc64"
|
|
1493
|
+
}
|
|
1494
|
+
return platform, arch
|
|
1495
|
+
}
|
|
1496
|
+
|
|
676
1497
|
// ttsxCommand builds an exec.Cmd that runs ttsx with the given args.
|
|
677
|
-
// When
|
|
1498
|
+
// When the resolved launcher has a script extension (.js, .ts, …) the binary is
|
|
678
1499
|
// invoked via the Node runtime so it is executed correctly on all platforms.
|
|
679
|
-
func ttsxCommand(args ...string) *exec.Cmd {
|
|
680
|
-
return ttsxCommandContext(context.Background(), args...)
|
|
1500
|
+
func ttsxCommand(anchors []string, args ...string) *exec.Cmd {
|
|
1501
|
+
return ttsxCommandContext(context.Background(), anchors, args...)
|
|
681
1502
|
}
|
|
682
1503
|
|
|
683
1504
|
// ttsxCommandContext is the context-bound variant used by config loaders. It
|
|
684
1505
|
// carries no deadline: evaluating a user config is the user's own code running,
|
|
685
1506
|
// and how long that is allowed to take is not this binary's decision.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1507
|
+
//
|
|
1508
|
+
// `anchors` are the file paths the launcher is resolved from; see
|
|
1509
|
+
// resolveTtsxLauncher.
|
|
1510
|
+
func ttsxCommandContext(ctx context.Context, anchors []string, args ...string) *exec.Cmd {
|
|
1511
|
+
ttsx := resolveTtsxLauncher(anchors)
|
|
691
1512
|
if shouldRunTtsxThroughNode(ttsx) {
|
|
692
1513
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
693
1514
|
if node == "" {
|
|
@@ -771,6 +1592,20 @@ func findNearestNodeModules(start string) string {
|
|
|
771
1592
|
}
|
|
772
1593
|
}
|
|
773
1594
|
|
|
1595
|
+
// relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
|
|
1596
|
+
// import specifier for location relative to fromDir.
|
|
1597
|
+
func relativeImportSpecifier(fromDir, location string) (string, error) {
|
|
1598
|
+
relative, err := filepath.Rel(fromDir, location)
|
|
1599
|
+
if err != nil {
|
|
1600
|
+
return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
|
|
1601
|
+
}
|
|
1602
|
+
relative = filepath.ToSlash(relative)
|
|
1603
|
+
if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
|
|
1604
|
+
return relative, nil
|
|
1605
|
+
}
|
|
1606
|
+
return "./" + relative, nil
|
|
1607
|
+
}
|
|
1608
|
+
|
|
774
1609
|
// setEnv returns a copy of env with key=value. If key already exists in env,
|
|
775
1610
|
// its value is updated in-place; otherwise the entry is appended.
|
|
776
1611
|
func setEnv(env []string, key, value string) []string {
|
|
@@ -784,12 +1619,6 @@ func setEnv(env []string, key, value string) []string {
|
|
|
784
1619
|
return append(env, prefix+value)
|
|
785
1620
|
}
|
|
786
1621
|
|
|
787
|
-
// sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
|
|
788
|
-
// by replacing "*/" with "* /" so the generated block comment stays valid.
|
|
789
|
-
func sanitizeJSDocLine(line string) string {
|
|
790
|
-
return strings.ReplaceAll(line, "*/", "* /")
|
|
791
|
-
}
|
|
792
|
-
|
|
793
1622
|
// loaderFailureReason reads the failure envelope a config loader writes to its
|
|
794
1623
|
// payload channel when it stops on an error it can name.
|
|
795
1624
|
//
|
|
@@ -809,3 +1638,5 @@ func loaderFailureReason(output []byte) string {
|
|
|
809
1638
|
}
|
|
810
1639
|
return strings.TrimSpace(envelope.Message)
|
|
811
1640
|
}
|
|
1641
|
+
|
|
1642
|
+
// ttsc:config-loader-shared end
|