@ttsc/lint 0.23.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.
@@ -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
 
@@ -1315,6 +1305,7 @@ const (
1315
1305
  configDependencyWatch = "watch"
1316
1306
  configDependencyFile = "file"
1317
1307
  configDependencyDir = "directory"
1308
+ configDependencyEntry = "entry"
1318
1309
  configDependencyOptionalFile = "optional-file"
1319
1310
  )
1320
1311
 
@@ -1376,7 +1367,10 @@ func loadConfigFileEvaluationWithin(
1376
1367
  // configCacheVersion namespaces the on-disk config cache. Bump it whenever
1377
1368
  // the shape of a cached config object changes so that entries written by an
1378
1369
  // older @ttsc/lint binary are treated as a miss rather than silently reused.
1379
- const configCacheVersion = "v5"
1370
+ // v6 adds the `entry` dependency kind. A v5 cache records a resolution trace's
1371
+ // ancestors as directory digests, so reusing it would keep republishing the
1372
+ // filesystem root as a watch input for as long as the entry survives.
1373
+ const configCacheVersion = "v6"
1380
1374
 
1381
1375
  // configEvalCache memoizes evaluated .ts/.js lint config objects for the
1382
1376
  // lifetime of one process; the on-disk cache (configCacheDir) extends the
@@ -1615,6 +1609,36 @@ func configDependencyDigest(
1615
1609
  }
1616
1610
  return hex.EncodeToString(h.Sum(nil)), nil
1617
1611
  }
1612
+ // The `entry` digest observes one path's own existence and link topology.
1613
+ // It must reproduce the loader script's encoding byte for byte, because the
1614
+ // script writes the fingerprint and this function is what later decides the
1615
+ // cached evaluation is still current.
1616
+ if dependency.Kind == configDependencyEntry {
1617
+ info, err := os.Lstat(dependency.Path)
1618
+ if err != nil {
1619
+ digest := sha256.Sum256([]byte("missing\x00"))
1620
+ return hex.EncodeToString(digest[:]), nil
1621
+ }
1622
+ if info.Mode()&os.ModeSymlink != 0 {
1623
+ target, err := os.Readlink(dependency.Path)
1624
+ if err != nil {
1625
+ target = "<unreadable>"
1626
+ }
1627
+ h := sha256.New()
1628
+ h.Write([]byte("symlink\x00"))
1629
+ h.Write([]byte(target))
1630
+ return hex.EncodeToString(h.Sum(nil)), nil
1631
+ }
1632
+ kind := "other"
1633
+ switch {
1634
+ case info.IsDir():
1635
+ kind = "directory"
1636
+ case info.Mode().IsRegular():
1637
+ kind = "file"
1638
+ }
1639
+ digest := sha256.Sum256([]byte(kind + "\x00"))
1640
+ return hex.EncodeToString(digest[:]), nil
1641
+ }
1618
1642
  if dependency.Kind == configDependencyOptionalFile {
1619
1643
  info, err := os.Stat(dependency.Path)
1620
1644
  if err != nil || !info.Mode().IsRegular() {
@@ -1747,39 +1771,31 @@ func serializableConfigKeysLiteral() string {
1747
1771
 
1748
1772
  // runConfigLoaderCommand runs a prepared config-loader subprocess (`cmd`),
1749
1773
  // then turns its result into a parsed config object. It owns the shared tail
1750
- // of both subprocess-backed loaders: discarding user stdout, distinguishing a
1751
- // timeout from a process error, reading the private result file, JSON-parsing
1752
- // its envelope, and rejecting a non-object result. `ctx` is the
1753
- // timeout context the caller bound `cmd` to; `location` is the config file path
1754
- // 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
1755
1778
  // file" or "TypeScript config file") spliced into the load/parse error
1756
1779
  // prefixes so each loader keeps its own wording.
1757
1780
  func runConfigLoaderCommand(
1758
- ctx context.Context,
1759
1781
  cmd *exec.Cmd,
1760
1782
  location string,
1761
1783
  label string,
1762
1784
  outputPath string,
1763
1785
  ) (evaluatedConfigFile, error) {
1764
- 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.
1765
1790
  cmd.Stdout = io.Discard
1766
- cmd.Stderr = &stderr
1791
+ cmd.Stderr = os.Stderr
1767
1792
  err := cmd.Run()
1768
- // A loader diagnostic is only useful when the load succeeds, because a
1769
- // failure already carries the same text in its message. Forward it so an
1770
- // assertion about what the loader recorded can name what it resolved.
1771
- if err == nil && os.Getenv("TTSC_LINT_DEBUG_CONFIG_GRAPH") != "" {
1772
- if text := strings.TrimSpace(stderr.String()); text != "" {
1773
- fmt.Fprintln(os.Stderr, text)
1774
- }
1775
- }
1776
1793
  if err != nil {
1777
- if ctx.Err() == context.DeadlineExceeded {
1778
- return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: timed out after %s", label, location, configLoaderTimeout)
1779
- }
1780
- stderrText := strings.TrimSpace(stderr.String())
1781
- if stderrText != "" {
1782
- 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)
1783
1799
  }
1784
1800
  return evaluatedConfigFile{}, fmt.Errorf("@ttsc/lint: load %s %s: %w", label, location, err)
1785
1801
  }
@@ -1836,6 +1852,7 @@ func normalizeConfigDependencyFingerprints(
1836
1852
  strings.ToLower(dependency.Digest) != dependency.Digest ||
1837
1853
  (dependency.Kind != configDependencyFile &&
1838
1854
  dependency.Kind != configDependencyDir &&
1855
+ dependency.Kind != configDependencyEntry &&
1839
1856
  dependency.Kind != configDependencyOptionalFile) ||
1840
1857
  (dependency.Scope != configDependencyCache &&
1841
1858
  dependency.Scope != configDependencyWatch) {
@@ -1872,8 +1889,7 @@ func normalizeConfigDependencyFingerprints(
1872
1889
  // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1873
1890
  // Node subprocess that dynamic-imports the file, resolves the exported config
1874
1891
  // through the same 8-hop default/config normalization used by the TS loader,
1875
- // and serializes the result into a private result file. The subprocess has a
1876
- // configLoaderTimeout deadline to prevent user code from hanging indefinitely.
1892
+ // and serializes the result into a private result file.
1877
1893
  func loadScriptConfigFile(location string) (any, error) {
1878
1894
  evaluated, err := loadScriptConfigEvaluation(location)
1879
1895
  return evaluated.value, err
@@ -1898,7 +1914,7 @@ func loadScriptConfigEvaluationWithin(
1898
1914
  if node == "" {
1899
1915
  node = "node"
1900
1916
  }
1901
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
1917
+ ctx, cancel := context.WithCancel(context.Background())
1902
1918
  defer cancel()
1903
1919
  cmd := exec.CommandContext(
1904
1920
  ctx,
@@ -1909,7 +1925,7 @@ func loadScriptConfigEvaluationWithin(
1909
1925
  outputPath,
1910
1926
  resolutionRoot,
1911
1927
  )
1912
- return runConfigLoaderCommand(ctx, cmd, location, "config file", outputPath)
1928
+ return runConfigLoaderCommand(cmd, location, "config file", outputPath)
1913
1929
  }
1914
1930
 
1915
1931
  // scriptConfigLoaderSource returns the CommonJS source of the loader script
@@ -2037,6 +2053,12 @@ const hooks = registerHooks({
2037
2053
  }
2038
2054
  })().catch((error) => {
2039
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 {}
2040
2062
  process.exit(1);
2041
2063
  });
2042
2064
 
@@ -2139,6 +2161,55 @@ function recordDirectoryDependency(location, owners) {
2139
2161
  }
2140
2162
  }
2141
2163
 
2164
+ // Observe one path's own existence and link topology instead of enumerating the
2165
+ // directory that contains it. A resolution trace passes through ancestors it
2166
+ // does not own -- /var on macOS is a symlink whose parent is the filesystem
2167
+ // root -- and digesting that parent both reaches outside the project boundary
2168
+ // and reads the whole directory to learn one entry's state.
2169
+ // A path candidate is observed through the directory that would own a
2170
+ // competing resolution, so a sibling winning extension resolution still
2171
+ // invalidates. That reasoning is what the parent digest is for and it stays.
2172
+ //
2173
+ // It does not reach the filesystem root. The root owns no candidate this trace
2174
+ // could pick, and a resolution path routinely passes through an ancestor
2175
+ // directly beneath it -- /var on macOS is a symlink whose parent is the root --
2176
+ // so digesting the parent there enumerates the entire filesystem root on every
2177
+ // config load, outside the project boundary. Record that one ancestor instead.
2178
+ function recordAncestorDependency(parent, entry, root, owners) {
2179
+ if (parent === root) recordEntryDependency(entry, owners);
2180
+ else recordDirectoryDependency(parent, owners);
2181
+ }
2182
+
2183
+ function recordEntryDependency(location, owners) {
2184
+ recordDependency("entry", location, entryDigest(location), owners);
2185
+ }
2186
+
2187
+ function entryDigest(location) {
2188
+ let entry;
2189
+ try {
2190
+ entry = fs.lstatSync(location);
2191
+ } catch {
2192
+ return createHash("sha256").update("missing\0").digest("hex");
2193
+ }
2194
+ if (entry.isSymbolicLink()) {
2195
+ let target;
2196
+ try {
2197
+ target = fs.readlinkSync(location, { encoding: "buffer" });
2198
+ } catch {
2199
+ target = Buffer.from("<unreadable>");
2200
+ }
2201
+ return createHash("sha256")
2202
+ .update(Buffer.concat([Buffer.from("symlink\0"), target]))
2203
+ .digest("hex");
2204
+ }
2205
+ const kind = entry.isDirectory()
2206
+ ? "directory"
2207
+ : entry.isFile()
2208
+ ? "file"
2209
+ : "other";
2210
+ return createHash("sha256").update(kind + "\0").digest("hex");
2211
+ }
2212
+
2142
2213
  function directoryDigest(location) {
2143
2214
  const entries = [];
2144
2215
  if (process.platform === "win32") {
@@ -2691,11 +2762,11 @@ function recordPackagePathCandidate(
2691
2762
  try {
2692
2763
  entry = fs.lstatSync(next);
2693
2764
  } catch {
2694
- recordDirectoryDependency(current, owners);
2765
+ recordAncestorDependency(current, next, parsed.root, owners);
2695
2766
  return;
2696
2767
  }
2697
2768
  if (entry.isSymbolicLink()) {
2698
- recordDirectoryDependency(current, owners);
2769
+ recordAncestorDependency(current, next, parsed.root, owners);
2699
2770
  try {
2700
2771
  const target = fs.readlinkSync(next);
2701
2772
  const remainder = components.slice(index + 1);
@@ -2717,16 +2788,20 @@ function recordPackagePathCandidate(
2717
2788
  }
2718
2789
  }
2719
2790
  if (index === components.length - 1) {
2720
- recordDirectoryDependency(isDirectory ? next : current, owners);
2791
+ if (isDirectory) recordDirectoryDependency(next, owners);
2792
+ else recordAncestorDependency(current, next, parsed.root, owners);
2721
2793
  return;
2722
2794
  }
2723
2795
  if (!isDirectory) {
2724
- recordDirectoryDependency(current, owners);
2796
+ recordAncestorDependency(current, next, parsed.root, owners);
2725
2797
  return;
2726
2798
  }
2727
2799
  current = next;
2728
2800
  }
2729
- recordDirectoryDependency(current, owners);
2801
+ // Reached only when the candidate resolved to the filesystem root itself,
2802
+ // which names no package. Record its existence, not its listing.
2803
+ if (current === parsed.root) recordEntryDependency(current, owners);
2804
+ else recordDirectoryDependency(current, owners);
2730
2805
  }
2731
2806
 
2732
2807
  function modulePackageName(specifier) {
@@ -2877,7 +2952,7 @@ function toSerializableConfig(value) {
2877
2952
 
2878
2953
  // loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
2879
2954
  // an ephemeral loader script and tsconfig into a temp directory, symlinking the
2880
- // nearest node_modules, then running `ttsx` with a configLoaderTimeout deadline.
2955
+ // nearest node_modules, then running `ttsx`.
2881
2956
  // The loader script imports the config file, resolves it through the same
2882
2957
  // normalization chain used by loadScriptConfigFile, and writes a private JSON
2883
2958
  // result file so user stdout cannot corrupt the protocol.
@@ -2958,11 +3033,11 @@ func loadTypeScriptConfigEvaluationWithin(
2958
3033
  }
2959
3034
  args = append(args, loader)
2960
3035
 
2961
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
3036
+ ctx, cancel := context.WithCancel(context.Background())
2962
3037
  defer cancel()
2963
3038
  cmd := ttsxCommandContext(ctx, args...)
2964
3039
  cmd.Env = nodeConfigLoaderEnv(location)
2965
- return runConfigLoaderCommand(ctx, cmd, location, "TypeScript config file", outputPath)
3040
+ return runConfigLoaderCommand(cmd, location, "TypeScript config file", outputPath)
2966
3041
  }
2967
3042
 
2968
3043
  // isConfigObject reports whether `value` is a top-level config object. A lint
@@ -3045,7 +3120,7 @@ const resolutionRoot = path.resolve(%s);
3045
3120
  const CONFIG_KEYS = new Set<string>([%s]);
3046
3121
  const dependencies = new Map<string, {
3047
3122
  digest: string;
3048
- kind: "directory" | "file" | "optional-file";
3123
+ kind: "directory" | "entry" | "file" | "optional-file";
3049
3124
  path: string;
3050
3125
  owners: Set<string>;
3051
3126
  }>();
@@ -3151,22 +3226,36 @@ const hooks = registerHooks({
3151
3226
  },
3152
3227
  });
3153
3228
 
3154
- try {
3155
- const importedConfig = await import(configUrl);
3156
- const value = await resolveConfig(importedConfig, true);
3157
- if (!isObject(value) || Array.isArray(value)) {
3158
- throw new Error("config file must export an ITtscLintConfig object");
3159
- }
3160
- fs.writeFileSync(outputPath, JSON.stringify({
3161
- dependencies: finalizeDependencies(),
3162
- value: toSerializableConfig(value),
3163
- }), "utf8");
3164
- } catch (error) {
3165
- process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
3166
- process.exit(1);
3167
- } finally {
3168
- hooks.deregister();
3169
- }
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
+ })();
3170
3259
 
3171
3260
  async function resolveConfig(value: unknown, allowNamedConfig: boolean): Promise<unknown> {
3172
3261
  let current = value;
@@ -3207,7 +3296,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
3207
3296
  }
3208
3297
 
3209
3298
  function recordDependency(
3210
- kind: "directory" | "file" | "optional-file",
3299
+ kind: "directory" | "entry" | "file" | "optional-file",
3211
3300
  location: string,
3212
3301
  digest: string,
3213
3302
  owners: readonly string[],
@@ -3271,6 +3360,63 @@ function recordDirectoryDependency(
3271
3360
  }
3272
3361
  }
3273
3362
 
3363
+ // Observe one path's own existence and link topology instead of enumerating the
3364
+ // directory that contains it. A resolution trace passes through ancestors it
3365
+ // does not own -- /var on macOS is a symlink whose parent is the filesystem
3366
+ // root -- and digesting that parent both reaches outside the project boundary
3367
+ // and reads the whole directory to learn one entry's state.
3368
+ // A path candidate is observed through the directory that would own a
3369
+ // competing resolution, so a sibling winning extension resolution still
3370
+ // invalidates. That reasoning is what the parent digest is for and it stays.
3371
+ //
3372
+ // It does not reach the filesystem root. The root owns no candidate this trace
3373
+ // could pick, and a resolution path routinely passes through an ancestor
3374
+ // directly beneath it -- /var on macOS is a symlink whose parent is the root --
3375
+ // so digesting the parent there enumerates the entire filesystem root on every
3376
+ // config load, outside the project boundary. Record that one ancestor instead.
3377
+ function recordAncestorDependency(
3378
+ parent: string,
3379
+ entry: string,
3380
+ root: string,
3381
+ owners: readonly string[],
3382
+ ): void {
3383
+ if (parent === root) recordEntryDependency(entry, owners);
3384
+ else recordDirectoryDependency(parent, owners);
3385
+ }
3386
+
3387
+ function recordEntryDependency(
3388
+ location: string,
3389
+ owners: readonly string[],
3390
+ ): void {
3391
+ recordDependency("entry", location, entryDigest(location), owners);
3392
+ }
3393
+
3394
+ function entryDigest(location: string): string {
3395
+ let entry: ReturnType<typeof fs.lstatSync>;
3396
+ try {
3397
+ entry = fs.lstatSync(location);
3398
+ } catch {
3399
+ return createHash("sha256").update("missing\0").digest("hex");
3400
+ }
3401
+ if (entry.isSymbolicLink()) {
3402
+ let target: Buffer;
3403
+ try {
3404
+ target = fs.readlinkSync(location, { encoding: "buffer" });
3405
+ } catch {
3406
+ target = Buffer.from("<unreadable>");
3407
+ }
3408
+ return createHash("sha256")
3409
+ .update(Buffer.concat([Buffer.from("symlink\0"), target]))
3410
+ .digest("hex");
3411
+ }
3412
+ const kind = entry.isDirectory()
3413
+ ? "directory"
3414
+ : entry.isFile()
3415
+ ? "file"
3416
+ : "other";
3417
+ return createHash("sha256").update(kind + "\0").digest("hex");
3418
+ }
3419
+
3274
3420
  function directoryDigest(location: string): string {
3275
3421
  const entries: Buffer[] = [];
3276
3422
  if (process.platform === "win32") {
@@ -3851,11 +3997,11 @@ function recordPackagePathCandidate(
3851
3997
  try {
3852
3998
  entry = fs.lstatSync(next);
3853
3999
  } catch {
3854
- recordDirectoryDependency(current, owners);
4000
+ recordAncestorDependency(current, next, parsed.root, owners);
3855
4001
  return;
3856
4002
  }
3857
4003
  if (entry.isSymbolicLink()) {
3858
- recordDirectoryDependency(current, owners);
4004
+ recordAncestorDependency(current, next, parsed.root, owners);
3859
4005
  try {
3860
4006
  const target = fs.readlinkSync(next);
3861
4007
  const remainder = components.slice(index + 1);
@@ -3877,16 +4023,20 @@ function recordPackagePathCandidate(
3877
4023
  }
3878
4024
  }
3879
4025
  if (index === components.length - 1) {
3880
- recordDirectoryDependency(isDirectory ? next : current, owners);
4026
+ if (isDirectory) recordDirectoryDependency(next, owners);
4027
+ else recordAncestorDependency(current, next, parsed.root, owners);
3881
4028
  return;
3882
4029
  }
3883
4030
  if (!isDirectory) {
3884
- recordDirectoryDependency(current, owners);
4031
+ recordAncestorDependency(current, next, parsed.root, owners);
3885
4032
  return;
3886
4033
  }
3887
4034
  current = next;
3888
4035
  }
3889
- recordDirectoryDependency(current, owners);
4036
+ // Reached only when the candidate resolved to the filesystem root itself,
4037
+ // which names no package. Record its existence, not its listing.
4038
+ if (current === parsed.root) recordEntryDependency(current, owners);
4039
+ else recordDirectoryDependency(current, owners);
3890
4040
  }
3891
4041
 
3892
4042
  function modulePackageName(specifier: string): string | undefined {
@@ -3964,7 +4114,7 @@ function realPath(location: string): string {
3964
4114
 
3965
4115
  function finalizeDependencies(): Array<{
3966
4116
  digest: string;
3967
- kind: "directory" | "file" | "optional-file";
4117
+ kind: "directory" | "entry" | "file" | "optional-file";
3968
4118
  path: string;
3969
4119
  scope: "cache" | "watch";
3970
4120
  }> {
@@ -4089,10 +4239,16 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4089
4239
  // false` is the right baseline.
4090
4240
  content := map[string]any{
4091
4241
  "compilerOptions": map[string]any{
4092
- "allowImportingTsExtensions": true,
4093
- "allowJs": true,
4094
- "checkJs": false,
4095
- "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),
4096
4252
  "moduleResolution": "bundler",
4097
4253
  "noImplicitAny": false,
4098
4254
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
@@ -4101,6 +4257,12 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4101
4257
  "skipLibCheck": true,
4102
4258
  "strict": false,
4103
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{"*"},
4104
4266
  },
4105
4267
  "files": []string{
4106
4268
  filepath.ToSlash(loader),
@@ -4114,6 +4276,56 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
4114
4276
  return string(body)
4115
4277
  }
4116
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
+
4117
4329
  // loaderRootDir returns the widest rootDir that still contains the loader
4118
4330
  // tsconfig's inputs: the volume root of the loader temp dir (`C:/` on
4119
4331
  // Windows, `/` elsewhere). A literal "/" is not an ancestor of drive-letter
@@ -4195,15 +4407,15 @@ func resolveDirLink(dir string) string {
4195
4407
  }
4196
4408
 
4197
4409
  // ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
4198
- // ttsxCommandContext when a deadline is needed (e.g. config file loading).
4410
+ // ttsxCommandContext when the caller owns a cancellable context.
4199
4411
  func ttsxCommand(args ...string) *exec.Cmd {
4200
4412
  return ttsxCommandContext(context.Background(), args...)
4201
4413
  }
4202
4414
 
4203
- // ttsxCommandContext is the timeout-aware variant. Callers that
4204
- // evaluate user-supplied config should wrap their context with
4205
- // `context.WithTimeout(parent, configLoaderTimeout)` so a runaway
4206
- // `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.
4207
4419
  func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
4208
4420
  ttsx := os.Getenv("TTSC_TTSX_BINARY")
4209
4421
  if ttsx == "" {
@@ -4572,3 +4784,30 @@ func (c RuleConfig) Severity(name string) Severity {
4572
4784
  }
4573
4785
  return SeverityOff
4574
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/linthost/fix.go CHANGED
@@ -83,8 +83,14 @@ func runFix(opts *subcommandOpts) int {
83
83
  // kinds of findings in one pass — no filtering needed here.
84
84
  cascadeConverged := false
85
85
  for pass := 0; pass < maxFixPasses; pass++ {
86
+ // Fix reads the whole lint scope because it prints diagnostics once the
87
+ // cascade settles, and writes only the project's own files: an imported
88
+ // source reaches the report below, never the disk.
86
89
  findings := prog.runLintCycle(engine)
87
- fixed, err := applyFindingFixes(opts.cwd, findings)
90
+ fixed, err := applyFindingFixes(
91
+ opts.cwd,
92
+ prog.projectWritableFindings(findings),
93
+ )
88
94
  if err != nil {
89
95
  fmt.Fprintln(os.Stderr, err)
90
96
  return 3
@@ -68,7 +68,9 @@ func runFormat(opts *subcommandOpts) int {
68
68
  totalFixes := 0
69
69
  cascadeConverged := false
70
70
  for pass := 0; pass < maxFormatPasses; pass++ {
71
- findings := prog.runLintCycle(engine)
71
+ // Format's cycle stays inside the project's own sources, so every finding
72
+ // it sees is one it may write.
73
+ findings := prog.runWriteScopedCycle(engine)
72
74
  fixed, err := applyFindingFixes(opts.cwd, filterFormatFindings(findings))
73
75
  if err != nil {
74
76
  fmt.Fprintln(os.Stderr, err)