@ttsc/lint 0.24.0 → 0.25.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.
@@ -1,33 +1,37 @@
1
- export declare const CONFIG_EVALUATOR_MAX_BUFFER: number;
2
- export declare const CONFIG_EVALUATOR_TIMEOUT_MS = 60000;
3
- export declare const CONFIG_EVALUATOR_TEARDOWN_GRACE_MS = 5000;
4
- export declare const CONFIG_EVALUATOR_STATUS_FD = 3;
5
- export declare const CONFIG_EVALUATOR_PROCESS_OPTIONS: Readonly<{
6
- killSignal: "SIGKILL";
7
- maxBuffer: number;
8
- timeout: number;
9
- }>;
10
1
  interface ConfigEvaluatorProcessResult {
11
2
  error?: Error;
12
- output?: readonly (string | null)[] | null;
13
3
  signal: NodeJS.Signals | null;
14
4
  status: number | null;
15
- stderr: string | null | undefined;
16
5
  }
17
6
  /**
18
7
  * Classify the ways the isolated lint-config evaluator can stop.
19
8
  *
20
- * Node reports both timeout and max-buffer termination with the configured
21
- * signal, so the process error code must take precedence over the signal. The
22
- * evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps
23
- * waiting when a POSIX child handles the default `SIGTERM` without exiting. A
24
- * bare signal is an external termination and a non-zero status is an evaluator
25
- * failure whose stderr tail contains the useful user-config diagnostic.
9
+ * The evaluator writes the child's own output straight to this process's
10
+ * stderr, so a diagnostic has already reached the user by the time anything
11
+ * here runs. What is left to say is only how the process ended: it never
12
+ * launched, something outside killed it, or it exited non-zero after printing
13
+ * its own reason.
14
+ *
15
+ * Nothing is bounded here — not time, not output. Both were the compiler
16
+ * deciding, on numbers nobody chose for this machine, that a user's own config
17
+ * had run too long or said too much. A slow config is a slow build the user can
18
+ * watch and interrupt; a loud one is output they asked for. Neither is this
19
+ * process's memory to spend either, because the child's streams are no longer
20
+ * collected into it.
26
21
  */
27
22
  export declare function configEvaluatorProcessFailure(result: ConfigEvaluatorProcessResult, configPath: string): Error | undefined;
28
23
  /**
29
- * Pass the semantic deadline and private status pipe through the `ttsx` wrapper
30
- * to the runtime child that actually executes the config.
24
+ * Read the failure envelope the evaluator writes to its result file when it
25
+ * stops on an error it can name.
26
+ *
27
+ * This is the other half of classifying how the evaluation ended, which is why
28
+ * it lives beside {@link configEvaluatorProcessFailure} rather than at the call
29
+ * site: the status says that it failed, and this says why.
30
+ *
31
+ * Only a well-formed envelope is honoured. A real evaluation payload never
32
+ * carries this key, and every other shape — an absent file, a build that failed
33
+ * before the loader ran, a half-written result, a payload written before a
34
+ * later non-zero exit — leaves the process status to speak for itself.
31
35
  */
32
- export declare function configEvaluatorBoundaryEnvironment(now?: number): NodeJS.ProcessEnv;
36
+ export declare function configEvaluatorFailureReason(outputPath: string): string;
33
37
  export {};
@@ -1,40 +1,28 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CONFIG_EVALUATOR_PROCESS_OPTIONS = exports.CONFIG_EVALUATOR_STATUS_FD = exports.CONFIG_EVALUATOR_TEARDOWN_GRACE_MS = exports.CONFIG_EVALUATOR_TIMEOUT_MS = exports.CONFIG_EVALUATOR_MAX_BUFFER = void 0;
4
6
  exports.configEvaluatorProcessFailure = configEvaluatorProcessFailure;
5
- exports.configEvaluatorBoundaryEnvironment = configEvaluatorBoundaryEnvironment;
6
- exports.CONFIG_EVALUATOR_MAX_BUFFER = 16 * 1024 * 1024;
7
- exports.CONFIG_EVALUATOR_TIMEOUT_MS = 60_000;
8
- exports.CONFIG_EVALUATOR_TEARDOWN_GRACE_MS = 5_000;
9
- exports.CONFIG_EVALUATOR_STATUS_FD = 3;
10
- exports.CONFIG_EVALUATOR_PROCESS_OPTIONS = Object.freeze({
11
- killSignal: "SIGKILL",
12
- maxBuffer: exports.CONFIG_EVALUATOR_MAX_BUFFER,
13
- timeout: exports.CONFIG_EVALUATOR_TIMEOUT_MS + exports.CONFIG_EVALUATOR_TEARDOWN_GRACE_MS,
14
- });
7
+ exports.configEvaluatorFailureReason = configEvaluatorFailureReason;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
15
9
  /**
16
10
  * Classify the ways the isolated lint-config evaluator can stop.
17
11
  *
18
- * Node reports both timeout and max-buffer termination with the configured
19
- * signal, so the process error code must take precedence over the signal. The
20
- * evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps
21
- * waiting when a POSIX child handles the default `SIGTERM` without exiting. A
22
- * bare signal is an external termination and a non-zero status is an evaluator
23
- * failure whose stderr tail contains the useful user-config diagnostic.
12
+ * The evaluator writes the child's own output straight to this process's
13
+ * stderr, so a diagnostic has already reached the user by the time anything
14
+ * here runs. What is left to say is only how the process ended: it never
15
+ * launched, something outside killed it, or it exited non-zero after printing
16
+ * its own reason.
17
+ *
18
+ * Nothing is bounded here — not time, not output. Both were the compiler
19
+ * deciding, on numbers nobody chose for this machine, that a user's own config
20
+ * had run too long or said too much. A slow config is a slow build the user can
21
+ * watch and interrupt; a loud one is output they asked for. Neither is this
22
+ * process's memory to spend either, because the child's streams are no longer
23
+ * collected into it.
24
24
  */
25
25
  function configEvaluatorProcessFailure(result, configPath) {
26
- const code = result.error?.code;
27
- const nestedCode = result.output?.[exports.CONFIG_EVALUATOR_STATUS_FD]?.trim() ?? "";
28
- if (code === "ETIMEDOUT" || nestedCode === "ETIMEDOUT") {
29
- return new Error(`@ttsc/lint: ttsx evaluation of ${configPath} timed out after ` +
30
- `${exports.CONFIG_EVALUATOR_TIMEOUT_MS / 1_000} seconds. ` +
31
- "Simplify the config or move heavy work out of top-level.");
32
- }
33
- if (code === "ENOBUFS" || nestedCode === "ENOBUFS") {
34
- return new Error(`@ttsc/lint: ttsx evaluation of ${configPath} exceeded the ` +
35
- `${exports.CONFIG_EVALUATOR_MAX_BUFFER / (1024 * 1024)} MiB output limit. ` +
36
- "Reduce console output from the config and its dependencies.");
37
- }
38
26
  if (result.error) {
39
27
  return new Error(`@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`);
40
28
  }
@@ -42,35 +30,34 @@ function configEvaluatorProcessFailure(result, configPath) {
42
30
  return new Error(`@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal}.`);
43
31
  }
44
32
  if (result.status !== 0) {
45
- const reason = configEvaluatorFailureReason(result.stderr);
46
- return new Error(`@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` +
47
- (reason === "" ? "" : "\n" + reason));
33
+ return new Error(`@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}`);
48
34
  }
49
35
  return undefined;
50
36
  }
51
37
  /**
52
- * Pass the semantic deadline and private status pipe through the `ttsx` wrapper
53
- * to the runtime child that actually executes the config.
54
- */
55
- function configEvaluatorBoundaryEnvironment(now = Date.now()) {
56
- return {
57
- TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(now + exports.CONFIG_EVALUATOR_TIMEOUT_MS),
58
- TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String(exports.CONFIG_EVALUATOR_MAX_BUFFER),
59
- TTSC_TTSX_EVALUATOR_STATUS_FD: String(exports.CONFIG_EVALUATOR_STATUS_FD),
60
- };
61
- }
62
- /**
63
- * Return the useful tail of evaluator stderr without turning an exception into
64
- * an unbounded duplicate of the already-forwarded child stream.
38
+ * Read the failure envelope the evaluator writes to its result file when it
39
+ * stops on an error it can name.
40
+ *
41
+ * This is the other half of classifying how the evaluation ended, which is why
42
+ * it lives beside {@link configEvaluatorProcessFailure} rather than at the call
43
+ * site: the status says that it failed, and this says why.
44
+ *
45
+ * Only a well-formed envelope is honoured. A real evaluation payload never
46
+ * carries this key, and every other shape — an absent file, a build that failed
47
+ * before the loader ran, a half-written result, a payload written before a
48
+ * later non-zero exit — leaves the process status to speak for itself.
65
49
  */
66
- function configEvaluatorFailureReason(stderr) {
67
- const bounded = (stderr ?? "").slice(-CONFIG_EVALUATOR_REASON_MAX_CHARS);
68
- const lines = bounded
69
- .split(/\r?\n/)
70
- .map((line) => line.trimEnd())
71
- .filter((line) => line.trim() !== "");
72
- return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n");
50
+ function configEvaluatorFailureReason(outputPath) {
51
+ try {
52
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(outputPath, "utf8"));
53
+ if (typeof parsed !== "object" || parsed === null)
54
+ return "";
55
+ const message = parsed
56
+ .__ttscLoaderError;
57
+ return typeof message === "string" ? message.trim() : "";
58
+ }
59
+ catch {
60
+ return "";
61
+ }
73
62
  }
74
- const CONFIG_EVALUATOR_REASON_LINES = 5;
75
- const CONFIG_EVALUATOR_REASON_MAX_CHARS = 8 * 1024;
76
63
  //# sourceMappingURL=configEvaluatorFailure.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"configEvaluatorFailure.js","sourceRoot":"","sources":["../../src/internal/configEvaluatorFailure.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"configEvaluatorFailure.js","sourceRoot":"","sources":["../../src/internal/configEvaluatorFailure.ts"],"names":[],"mappings":";;;;;;;AAAA,sDAAyB;AAQzB;;;;;;;;;;;;;;;GAeG;AACH,uCACE,MAAoC,EACpC,UAAkB;IAElB,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,IAAI,KAAK,CACd,wCAAwC,UAAU,KAAK,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,CAC9E,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QAClB,OAAO,IAAI,KAAK,CACd,kCAAkC,UAAU,yBAAyB,MAAM,CAAC,MAAM,GAAG,CACtF,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,IAAI,KAAK,CACd,2BAA2B,UAAU,qCAAqC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAClG,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,sCAA6C,UAAkB;IAC7D,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,iBAAE,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;QACxE,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,EAAE,CAAC;QAC7D,MAAM,OAAO,GAAI,MAA0C;aACxD,iBAAiB,CAAC;QACrB,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC3D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC"}
@@ -16,20 +16,10 @@ import (
16
16
  "sort"
17
17
  "strings"
18
18
  "sync"
19
- "time"
20
19
 
21
20
  "github.com/samchon/ttsc/packages/ttsc/driver/windowsjunction"
22
21
  )
23
22
 
24
- // configLoaderTimeout caps every `ttsx`/`node -e` subprocess that
25
- // evaluates a user-supplied lint config. The JS factory imposes the
26
- // same 60 s budget on its mirroring spawnSync; without the Go-side cap
27
- // a runaway user config would hang `ttsc-lint` forever, while
28
- // `ttsc`/`pnpm` upstream of it stays responsive. 60 s is generous
29
- // enough for cold ttsx starts on CI runners and tight enough to keep
30
- // user-visible feedback under a minute.
31
- const configLoaderTimeout = 60 * time.Second
32
-
33
23
  // Severity is the `error | warning | off` ladder.
34
24
  type Severity int
35
25
 
@@ -1781,39 +1771,31 @@ func serializableConfigKeysLiteral() string {
1781
1771
 
1782
1772
  // runConfigLoaderCommand runs a prepared config-loader subprocess (`cmd`),
1783
1773
  // then turns its result into a parsed config object. It owns the shared tail
1784
- // of both subprocess-backed loaders: discarding user stdout, distinguishing a
1785
- // timeout from a process error, reading the private result file, JSON-parsing
1786
- // its envelope, and rejecting a non-object result. `ctx` is the
1787
- // timeout context the caller bound `cmd` to; `location` is the config file path
1788
- // for error messages; `label` is the human-readable subject (e.g. "config
1774
+ // of both subprocess-backed loaders: discarding user stdout, streaming user
1775
+ // stderr through, reading the private result file, JSON-parsing its envelope,
1776
+ // and rejecting a non-object result. `location` is the config file
1777
+ // path for error messages; `label` is the human-readable subject (e.g. "config
1789
1778
  // file" or "TypeScript config file") spliced into the load/parse error
1790
1779
  // prefixes so each loader keeps its own wording.
1791
1780
  func runConfigLoaderCommand(
1792
- ctx context.Context,
1793
1781
  cmd *exec.Cmd,
1794
1782
  location string,
1795
1783
  label string,
1796
1784
  outputPath string,
1797
1785
  ) (evaluatedConfigFile, error) {
1798
- var stderr bytes.Buffer
1786
+ // The child's stderr is human output and goes straight to this process's
1787
+ // stderr as it is written. Collecting it only to replay it afterwards is what
1788
+ // made a long evaluation print nothing at all, and what would make a loud one
1789
+ // grow this process's memory without bound.
1799
1790
  cmd.Stdout = io.Discard
1800
- cmd.Stderr = &stderr
1791
+ cmd.Stderr = os.Stderr
1801
1792
  err := cmd.Run()
1802
- // A loader diagnostic is only useful when the load succeeds, because a
1803
- // failure already carries the same text in its message. Forward it so an
1804
- // assertion about what the loader recorded can name what it resolved.
1805
- if err == nil && os.Getenv("TTSC_LINT_DEBUG_CONFIG_GRAPH") != "" {
1806
- if text := strings.TrimSpace(stderr.String()); text != "" {
1807
- fmt.Fprintln(os.Stderr, text)
1808
- }
1809
- }
1810
1793
  if err != nil {
1811
- if ctx.Err() == context.DeadlineExceeded {
1812
- return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: timed out after %s", label, location, configLoaderTimeout)
1813
- }
1814
- stderrText := strings.TrimSpace(stderr.String())
1815
- if stderrText != "" {
1816
- return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %s", label, location, stderrText)
1794
+ // The loader's stack already reached the user's stderr as it was written.
1795
+ // What it could not put there is a reason a caller can act on, so that
1796
+ // arrives through the result file instead.
1797
+ if reason := loaderFailureReason(outputPath); reason != "" {
1798
+ return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %s", label, location, reason)
1817
1799
  }
1818
1800
  return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %w", label, location, err)
1819
1801
  }
@@ -1907,8 +1889,7 @@ func normalizeConfigDependencyFingerprints(
1907
1889
  // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1908
1890
  // Node subprocess that dynamic-imports the file, resolves the exported config
1909
1891
  // through the same 8-hop default/config normalization used by the TS loader,
1910
- // and serializes the result into a private result file. The subprocess has a
1911
- // configLoaderTimeout deadline to prevent user code from hanging indefinitely.
1892
+ // and serializes the result into a private result file.
1912
1893
  func loadScriptConfigFile(location string) (any, error) {
1913
1894
  evaluated, err := loadScriptConfigEvaluation(location)
1914
1895
  return evaluated.value, err
@@ -1933,7 +1914,7 @@ func loadScriptConfigEvaluationWithin(
1933
1914
  if node == "" {
1934
1915
  node = "node"
1935
1916
  }
1936
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
1917
+ ctx, cancel := context.WithCancel(context.Background())
1937
1918
  defer cancel()
1938
1919
  cmd := exec.CommandContext(
1939
1920
  ctx,
@@ -1944,7 +1925,7 @@ func loadScriptConfigEvaluationWithin(
1944
1925
  outputPath,
1945
1926
  resolutionRoot,
1946
1927
  )
1947
- return runConfigLoaderCommand(ctx, cmd, location, "config file", outputPath)
1928
+ return runConfigLoaderCommand(cmd, location, "config file", outputPath)
1948
1929
  }
1949
1930
 
1950
1931
  // scriptConfigLoaderSource returns the CommonJS source of the loader script
@@ -2072,6 +2053,12 @@ const hooks = registerHooks({
2072
2053
  }
2073
2054
  })().catch((error) => {
2074
2055
  process.stderr.write(error && error.stack ? error.stack : String(error));
2056
+ // The stack above is for the reader. This is for the caller: the parent reads
2057
+ // the result file either way, so a failure reason travels as data rather than
2058
+ // as text scraped back out of a captured stream.
2059
+ try {
2060
+ fs.writeFileSync(outputPath, JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), "utf8");
2061
+ } catch {}
2075
2062
  process.exit(1);
2076
2063
  });
2077
2064
 
@@ -2965,7 +2952,7 @@ function toSerializableConfig(value) {
2965
2952
 
2966
2953
  // loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
2967
2954
  // an ephemeral loader script and tsconfig into a temp directory, symlinking the
2968
- // nearest node_modules, then running `ttsx` with a configLoaderTimeout deadline.
2955
+ // nearest node_modules, then running `ttsx`.
2969
2956
  // The loader script imports the config file, resolves it through the same
2970
2957
  // normalization chain used by loadScriptConfigFile, and writes a private JSON
2971
2958
  // result file so user stdout cannot corrupt the protocol.
@@ -3046,11 +3033,11 @@ func loadTypeScriptConfigEvaluationWithin(
3046
3033
  }
3047
3034
  args = append(args, loader)
3048
3035
 
3049
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
3036
+ ctx, cancel := context.WithCancel(context.Background())
3050
3037
  defer cancel()
3051
3038
  cmd := ttsxCommandContext(ctx, args...)
3052
3039
  cmd.Env = nodeConfigLoaderEnv(location)
3053
- return runConfigLoaderCommand(ctx, cmd, location, "TypeScript config file", outputPath)
3040
+ return runConfigLoaderCommand(cmd, location, "TypeScript config file", outputPath)
3054
3041
  }
3055
3042
 
3056
3043
  // isConfigObject reports whether `value` is a top-level config object. A lint
@@ -3239,22 +3226,36 @@ const hooks = registerHooks({
3239
3226
  },
3240
3227
  });
3241
3228
 
3242
- try {
3243
- const importedConfig = await import(configUrl);
3244
- const value = await resolveConfig(importedConfig, true);
3245
- if (!isObject(value) || Array.isArray(value)) {
3246
- throw new Error("config file must export an ITtscLintConfig object");
3247
- }
3248
- fs.writeFileSync(outputPath, JSON.stringify({
3249
- dependencies: finalizeDependencies(),
3250
- value: toSerializableConfig(value),
3251
- }), "utf8");
3252
- } catch (error) {
3253
- process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
3254
- process.exit(1);
3255
- } finally {
3256
- hooks.deregister();
3257
- }
3229
+ // Wrapped rather than written as a top-level await: the loader tsconfig's
3230
+ // "module" follows the config's own package, and TS1378 rejects top-level await
3231
+ // under a CommonJS module option however this .mts file emits. The body's own
3232
+ // catch is the only failure path — it ends the process — so there is nothing
3233
+ // left for a trailing handler to settle.
3234
+ (async () => {
3235
+ try {
3236
+ const importedConfig = await import(configUrl);
3237
+ const value = await resolveConfig(importedConfig, true);
3238
+ if (!isObject(value) || Array.isArray(value)) {
3239
+ throw new Error("config file must export an ITtscLintConfig object");
3240
+ }
3241
+ fs.writeFileSync(outputPath, JSON.stringify({
3242
+ dependencies: finalizeDependencies(),
3243
+ value: toSerializableConfig(value),
3244
+ }), "utf8");
3245
+ } catch (error) {
3246
+ process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
3247
+ // The stack above is for the reader. This is for the caller: the parent
3248
+ // reads the result file either way, so a failure reason travels as data
3249
+ // rather than as text scraped back out of a captured stream. A write that
3250
+ // itself fails leaves the process status to speak.
3251
+ try {
3252
+ fs.writeFileSync(outputPath, JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }), "utf8");
3253
+ } catch {}
3254
+ process.exit(1);
3255
+ } finally {
3256
+ hooks.deregister();
3257
+ }
3258
+ })();
3258
3259
 
3259
3260
  async function resolveConfig(value: unknown, allowNamedConfig: boolean): Promise<unknown> {
3260
3261
  let current = value;
@@ -4238,10 +4239,16 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4238
4239
  // false` is the right baseline.
4239
4240
  content := map[string]any{
4240
4241
  "compilerOptions": map[string]any{
4241
- "allowImportingTsExtensions": true,
4242
- "allowJs": true,
4243
- "checkJs": false,
4244
- "module": "ESNext",
4242
+ "allowImportingTsExtensions": true,
4243
+ "allowJs": true,
4244
+ "checkJs": false,
4245
+ // The config is a Node module, so Node's rule decides its format: the
4246
+ // nearest package.json "type" above it. Hardcoding one answer ran every
4247
+ // ambiguous `.ts` config as ESM and broke __dirname in an ordinary
4248
+ // CommonJS package (#1068). moduleResolution stays "bundler", which tsgo
4249
+ // accepts for both kinds, so extensionless relative imports keep
4250
+ // resolving either way.
4251
+ "module": configModuleOption(location),
4245
4252
  "moduleResolution": "bundler",
4246
4253
  "noImplicitAny": false,
4247
4254
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
@@ -4250,6 +4257,12 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4250
4257
  "skipLibCheck": true,
4251
4258
  "strict": false,
4252
4259
  "target": "ES2022",
4260
+ // TypeScript 7 includes no ambient type package unless "types" asks for
4261
+ // it, and this Program extends nothing, so without the wildcard a config
4262
+ // could not name a single Node global (#1068). The loader directory links
4263
+ // the config's nearest node_modules, so the default typeRoots walk finds
4264
+ // exactly what the project installed.
4265
+ "types": []string{"*"},
4253
4266
  },
4254
4267
  "files": []string{
4255
4268
  filepath.ToSlash(loader),
@@ -4263,6 +4276,56 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4263
4276
  return string(body)
4264
4277
  }
4265
4278
 
4279
+ // configModuleOption returns the loader tsconfig's "module" for a config file:
4280
+ // the module kind Node itself would give that file.
4281
+ //
4282
+ // An explicit .cts/.cjs or .mts/.mjs extension already decides the emit format
4283
+ // on its own, so those keep the ES-module setting and let the extension win —
4284
+ // the same precedence tsgo applies. Everything ambiguous walks up for the
4285
+ // nearest package.json "type", exactly as Node does when it loads the file.
4286
+ func configModuleOption(location string) string {
4287
+ switch strings.ToLower(filepath.Ext(location)) {
4288
+ case ".ts", ".tsx", ".js":
4289
+ if nearestPackageType(location) == "commonjs" {
4290
+ return "CommonJS"
4291
+ }
4292
+ }
4293
+ return "ESNext"
4294
+ }
4295
+
4296
+ // nearestPackageType mirrors Node's package-scope lookup for the nearest
4297
+ // package.json above location: the walk stops at the FIRST manifest it finds,
4298
+ // and a manifest declaring no "type" means CommonJS rather than a reason to
4299
+ // keep climbing. Reaching the filesystem root without any manifest also means
4300
+ // CommonJS. The location is made absolute first, so a relative config path
4301
+ // cannot end the walk at "." after a single step.
4302
+ func nearestPackageType(location string) string {
4303
+ absolute, err := filepath.Abs(location)
4304
+ if err != nil {
4305
+ absolute = location
4306
+ }
4307
+ dir := filepath.Dir(absolute)
4308
+ for {
4309
+ raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
4310
+ if err == nil {
4311
+ var manifest struct {
4312
+ Type string `json:"type"`
4313
+ }
4314
+ // A manifest that does not parse still bounds the package scope; Node
4315
+ // refuses to look past it, and CommonJS is the format it defaults to.
4316
+ if json.Unmarshal(raw, &manifest) == nil && manifest.Type == "module" {
4317
+ return "module"
4318
+ }
4319
+ return "commonjs"
4320
+ }
4321
+ parent := filepath.Dir(dir)
4322
+ if parent == dir {
4323
+ return "commonjs"
4324
+ }
4325
+ dir = parent
4326
+ }
4327
+ }
4328
+
4266
4329
  // loaderRootDir returns the widest rootDir that still contains the loader
4267
4330
  // tsconfig's inputs: the volume root of the loader temp dir (`C:/` on
4268
4331
  // Windows, `/` elsewhere). A literal "/" is not an ancestor of drive-letter
@@ -4344,15 +4407,15 @@ func resolveDirLink(dir string) string {
4344
4407
  }
4345
4408
 
4346
4409
  // ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
4347
- // ttsxCommandContext when a deadline is needed (e.g. config file loading).
4410
+ // ttsxCommandContext when the caller owns a cancellable context.
4348
4411
  func ttsxCommand(args ...string) *exec.Cmd {
4349
4412
  return ttsxCommandContext(context.Background(), args...)
4350
4413
  }
4351
4414
 
4352
- // ttsxCommandContext is the timeout-aware variant. Callers that
4353
- // evaluate user-supplied config should wrap their context with
4354
- // `context.WithTimeout(parent, configLoaderTimeout)` so a runaway
4355
- // `ttsx` subprocess can never hang the lint binary indefinitely.
4415
+ // ttsxCommandContext is the cancellable variant, used by the config loaders so
4416
+ // their subprocess is torn down with the call that started it. It carries no
4417
+ // deadline: evaluating a user config is the user's own code running, and how
4418
+ // long that is allowed to take is not this binary's decision.
4356
4419
  func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
4357
4420
  ttsx := os.Getenv("TTSC_TTSX_BINARY")
4358
4421
  if ttsx == "" {
@@ -4721,3 +4784,30 @@ func (c RuleConfig) Severity(name string) Severity {
4721
4784
  }
4722
4785
  return SeverityOff
4723
4786
  }
4787
+
4788
+ // loaderFailureReason reads the failure envelope a config loader writes to its
4789
+ // private result file when it stops on an error it can name.
4790
+ //
4791
+ // The loader's stack goes to this process's stderr as it runs, which is where a
4792
+ // reader wants it. But the reason — "config file must export an ITtscLintConfig
4793
+ // object" — is a fact about the user's config, and a caller deserves it in the
4794
+ // error rather than having to go find it in the log. Only a well-formed
4795
+ // envelope is honoured; anything else leaves the process status to speak.
4796
+ //
4797
+ // The key is `__ttscLoaderError` — the same spelling every other ttsc loader
4798
+ // writes, and namespaced so it cannot collide with a payload field. This file
4799
+ // spends "error" on rule severity, which is exactly the confusion a shared,
4800
+ // prefixed key avoids.
4801
+ func loaderFailureReason(outputPath string) string {
4802
+ raw, err := os.ReadFile(outputPath)
4803
+ if err != nil {
4804
+ return ""
4805
+ }
4806
+ var envelope struct {
4807
+ Error string `json:"__ttscLoaderError"`
4808
+ }
4809
+ if json.Unmarshal(raw, &envelope) != nil {
4810
+ return ""
4811
+ }
4812
+ return strings.TrimSpace(envelope.Error)
4813
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Reference ttsc plugin: ESLint-style lint rules over the TypeScript-Go Program used by the type-check pass.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -37,7 +37,7 @@
37
37
  "@types/node": "^25.3.0",
38
38
  "rimraf": "^6.1.2",
39
39
  "typescript": "^7.0.2",
40
- "ttsc": "0.24.0"
40
+ "ttsc": "0.25.0"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",