@ttsc/banner 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/driver/banner.go +143 -38
  2. package/package.json +2 -2
package/driver/banner.go CHANGED
@@ -10,17 +10,11 @@ import (
10
10
  "path/filepath"
11
11
  "runtime"
12
12
  "strings"
13
- "time"
14
13
 
15
14
  "github.com/samchon/ttsc/packages/ttsc/driver"
16
15
  "github.com/samchon/ttsc/packages/ttsc/driver/windowsjunction"
17
16
  )
18
17
 
19
- // configLoaderTimeout caps subprocesses that evaluate user-supplied banner
20
- // config files. This matches the strip/lint loaders so a hanging config does
21
- // not block the compiler indefinitely.
22
- const configLoaderTimeout = 60 * time.Second
23
-
24
18
  func init() {
25
19
  driver.RegisterPlugin(plugin{})
26
20
  }
@@ -316,7 +310,14 @@ const { pathToFileURL } = require("node:url");
316
310
  process.stdout.write(JSON.stringify(toSerializableBanner(value)));
317
311
  })().catch((error) => {
318
312
  process.stderr.write(error && error.stack ? error.stack : String(error));
319
- process.exit(1);
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
+ process.exitCode = 1;
320
+ process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
320
321
  });
321
322
 
322
323
  function toSerializableBanner(value) {
@@ -330,21 +331,22 @@ function toSerializableBanner(value) {
330
331
  if node == "" {
331
332
  node = "node"
332
333
  }
333
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
334
+ ctx, cancel := context.WithCancel(context.Background())
334
335
  defer cancel()
335
336
  cmd := exec.CommandContext(ctx, node, "-e", script, location)
336
337
  cmd.Env = nodeConfigLoaderEnv(location)
338
+ // The child's stderr is human output and goes straight to this process's
339
+ // stderr as it is written. Collecting it only to replay it afterwards is what
340
+ // made a long evaluation print nothing at all, and what would make a loud one
341
+ // grow this process's memory without bound.
342
+ cmd.Stderr = os.Stderr
337
343
  output, err := cmd.Output()
338
344
  if err != nil {
339
- if ctx.Err() == context.DeadlineExceeded {
340
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: timed out after %s", location, configLoaderTimeout)
341
- }
342
- stderr := ""
343
- if exit, ok := err.(*exec.ExitError); ok {
344
- stderr = strings.TrimSpace(string(exit.Stderr))
345
- }
346
- if stderr != "" {
347
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, stderr)
345
+ // The loader's stack already reached this process's stderr as it ran.
346
+ // What it could not put there is a reason a caller can act on, so that
347
+ // arrives through the payload channel instead.
348
+ if reason := loaderFailureReason(output); reason != "" {
349
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, reason)
348
350
  }
349
351
  return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
350
352
  }
@@ -396,21 +398,22 @@ func loadBannerTypeScriptConfigFile(location string) (any, error) {
396
398
  }
397
399
  args = append(args, loader)
398
400
 
399
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
401
+ ctx, cancel := context.WithCancel(context.Background())
400
402
  defer cancel()
401
403
  cmd := ttsxCommandContext(ctx, args...)
402
404
  cmd.Env = nodeConfigLoaderEnv(location)
405
+ // The child's stderr is human output and goes straight to this process's
406
+ // stderr as it is written. Collecting it only to replay it afterwards is what
407
+ // made a long evaluation print nothing at all, and what would make a loud one
408
+ // grow this process's memory without bound.
409
+ cmd.Stderr = os.Stderr
403
410
  output, err := cmd.Output()
404
411
  if err != nil {
405
- if ctx.Err() == context.DeadlineExceeded {
406
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: timed out after %s", location, configLoaderTimeout)
407
- }
408
- stderr := ""
409
- if exit, ok := err.(*exec.ExitError); ok {
410
- stderr = strings.TrimSpace(string(exit.Stderr))
411
- }
412
- if stderr != "" {
413
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, stderr)
412
+ // The loader's stack already reached this process's stderr as it ran.
413
+ // What it could not put there is a reason a caller can act on, so that
414
+ // arrives through the payload channel instead.
415
+ if reason := loaderFailureReason(output); reason != "" {
416
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, reason)
414
417
  }
415
418
  return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
416
419
  }
@@ -442,18 +445,36 @@ func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
442
445
  return fmt.Sprintf(`import * as importedConfig from %s;
443
446
 
444
447
  declare const process: {
445
- stdout: { write(value: string): void };
448
+ exitCode?: number;
449
+ stdout: { write(value: string, callback?: () => void): void };
446
450
  stderr: { write(value: string): void };
447
451
  exit(code?: number): never;
448
452
  };
449
453
 
450
- try {
451
- const value = await resolveConfig(importedConfig);
452
- process.stdout.write(JSON.stringify(toSerializableBanner(value)));
453
- } catch (error) {
454
- process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
455
- process.exit(1);
456
- }
454
+ // Wrapped rather than written as a top-level await: the loader tsconfig's
455
+ // "module" follows the config's own package, and TS1378 rejects top-level await
456
+ // under a CommonJS module option however this .mts file emits. The body's own
457
+ // catch is the only failure path — it ends the process — so there is nothing
458
+ // left for a trailing handler to settle.
459
+ (async () => {
460
+ try {
461
+ const value = await resolveConfig(importedConfig);
462
+ process.stdout.write(JSON.stringify(toSerializableBanner(value)));
463
+ } catch (error) {
464
+ process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
465
+ // The stack above is for the reader. This is for the caller: the parent
466
+ // reads stdout as the payload channel either way, so a failure reason
467
+ // travels as data rather than as text scraped back out of a captured
468
+ // stream. The exit code is set before the write so a callback that never
469
+ // fires still fails the load, and the write's completion is what triggers
470
+ // the exit, because process.exit abandons a pending pipe write.
471
+ process.exitCode = 1;
472
+ process.stdout.write(
473
+ JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }),
474
+ () => process.exit(1),
475
+ );
476
+ }
477
+ })();
457
478
 
458
479
  async function resolveConfig(value: unknown): Promise<unknown> {
459
480
  let current = isObject(value) && hasOwn(value, "default") ? value.default : value;
@@ -499,8 +520,14 @@ function toSerializableBanner(value: unknown): unknown {
499
520
  func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
500
521
  content := map[string]any{
501
522
  "compilerOptions": map[string]any{
502
- "allowImportingTsExtensions": true,
503
- "module": "ESNext",
523
+ "allowImportingTsExtensions": true,
524
+ // The config is a Node module, so Node's rule decides its format: the
525
+ // nearest package.json "type" above it. Hardcoding one answer ran every
526
+ // ambiguous `.ts` config as ESM and broke __dirname in an ordinary
527
+ // CommonJS package (#1069). moduleResolution stays "bundler", which tsgo
528
+ // accepts for both kinds, so extensionless relative imports keep
529
+ // resolving either way.
530
+ "module": configModuleOption(location),
504
531
  "moduleResolution": "bundler",
505
532
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
506
533
  "rewriteRelativeImportExtensions": true,
@@ -508,6 +535,12 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
508
535
  "skipLibCheck": true,
509
536
  "strict": true,
510
537
  "target": "ES2022",
538
+ // TypeScript 7 includes no ambient type package unless "types" asks for
539
+ // it, and this Program extends nothing, so without the wildcard a config
540
+ // could not name a single Node global (#1069). The loader directory links
541
+ // the config's nearest node_modules, so the default typeRoots walk finds
542
+ // exactly what the project installed.
543
+ "types": []string{"*"},
511
544
  },
512
545
  "files": []string{
513
546
  filepath.ToSlash(loader),
@@ -518,6 +551,56 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
518
551
  return string(body)
519
552
  }
520
553
 
554
+ // configModuleOption returns the loader tsconfig's "module" for a config file:
555
+ // the module kind Node itself would give that file.
556
+ //
557
+ // An explicit .cts/.cjs or .mts/.mjs extension already decides the emit format
558
+ // on its own, so those keep the ES-module setting and let the extension win —
559
+ // the same precedence tsgo applies. Everything ambiguous walks up for the
560
+ // nearest package.json "type", exactly as Node does when it loads the file.
561
+ func configModuleOption(location string) string {
562
+ switch strings.ToLower(filepath.Ext(location)) {
563
+ case ".ts", ".tsx", ".js":
564
+ if nearestPackageType(location) == "commonjs" {
565
+ return "CommonJS"
566
+ }
567
+ }
568
+ return "ESNext"
569
+ }
570
+
571
+ // nearestPackageType mirrors Node's package-scope lookup for the nearest
572
+ // package.json above location: the walk stops at the FIRST manifest it finds,
573
+ // and a manifest declaring no "type" means CommonJS rather than a reason to
574
+ // keep climbing. Reaching the filesystem root without any manifest also means
575
+ // CommonJS. The location is made absolute first, so a relative config path
576
+ // cannot end the walk at "." after a single step.
577
+ func nearestPackageType(location string) string {
578
+ absolute, err := filepath.Abs(location)
579
+ if err != nil {
580
+ absolute = location
581
+ }
582
+ dir := filepath.Dir(absolute)
583
+ for {
584
+ raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
585
+ if err == nil {
586
+ var manifest struct {
587
+ Type string `json:"type"`
588
+ }
589
+ // A manifest that does not parse still bounds the package scope; Node
590
+ // refuses to look past it, and CommonJS is the format it defaults to.
591
+ if json.Unmarshal(raw, &manifest) == nil && manifest.Type == "module" {
592
+ return "module"
593
+ }
594
+ return "commonjs"
595
+ }
596
+ parent := filepath.Dir(dir)
597
+ if parent == dir {
598
+ return "commonjs"
599
+ }
600
+ dir = parent
601
+ }
602
+ }
603
+
521
604
  // loaderRootDir returns the widest rootDir that still contains the loader
522
605
  // tsconfig's inputs: the volume root of the loader temp dir (`C:/` on
523
606
  // Windows, `/` elsewhere). A literal "/" is not an ancestor of drive-letter
@@ -597,7 +680,9 @@ func ttsxCommand(args ...string) *exec.Cmd {
597
680
  return ttsxCommandContext(context.Background(), args...)
598
681
  }
599
682
 
600
- // ttsxCommandContext is the timeout-aware variant used by config loaders.
683
+ // ttsxCommandContext is the context-bound variant used by config loaders. It
684
+ // carries no deadline: evaluating a user config is the user's own code running,
685
+ // and how long that is allowed to take is not this binary's decision.
601
686
  func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
602
687
  ttsx := os.Getenv("TTSC_TTSX_BINARY")
603
688
  if ttsx == "" {
@@ -704,3 +789,23 @@ func setEnv(env []string, key, value string) []string {
704
789
  func sanitizeJSDocLine(line string) string {
705
790
  return strings.ReplaceAll(line, "*/", "* /")
706
791
  }
792
+
793
+ // loaderFailureReason reads the failure envelope a config loader writes to its
794
+ // payload channel when it stops on an error it can name.
795
+ //
796
+ // The loader's stack goes to this process's stderr as it runs, which is where a
797
+ // reader wants it. But the *reason* — "config file must export an object with a
798
+ // non-empty text string" — is a fact about the user's config, and a caller
799
+ // deserves it in the error rather than having to go find it in the log. So it
800
+ // travels as data through the same stdout the payload uses, and only a
801
+ // well-formed envelope is honoured: anything else leaves the process status to
802
+ // speak for itself.
803
+ func loaderFailureReason(output []byte) string {
804
+ var envelope struct {
805
+ Message string `json:"__ttscLoaderError"`
806
+ }
807
+ if json.Unmarshal(output, &envelope) != nil {
808
+ return ""
809
+ }
810
+ return strings.TrimSpace(envelope.Message)
811
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.24.0",
3
+ "version": "0.26.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.24.0"
38
+ "ttsc": "0.26.0"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",