@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.
package/linthost/host.go CHANGED
@@ -42,6 +42,15 @@ type program struct {
42
42
  checker *shimchecker.Checker
43
43
  identity publicrule.ProjectIdentity
44
44
  projectCycle *projectCycle
45
+ // projectRoots memoizes projectSourceFileNames, which resolves every
46
+ // selected file's path and therefore costs filesystem work. The tsconfig
47
+ // selection cannot change while one program is loaded — a config edit or a
48
+ // new or removed file forces a full reload upstream rather than an
49
+ // applyChange — while every read and every write consults the set at least
50
+ // once per cycle, and a fix or format cascade repeats that per pass. Filled
51
+ // lazily under the same single-threaded assumption projectCycle already
52
+ // makes, and read-only to its callers.
53
+ projectRoots map[string]struct{}
45
54
  }
46
55
 
47
56
  type loadProgramOptions struct {
@@ -274,11 +283,38 @@ func (p *program) runProjectCycle(engine *Engine) *projectCycle {
274
283
  return p.projectCycle
275
284
  }
276
285
 
286
+ // runLintCycle walks everything the invocation reads: the project's own sources
287
+ // and the TypeScript it imported. Every caller here reports its findings, so a
288
+ // source the type-check pass read must be able to produce one.
277
289
  func (p *program) runLintCycle(engine *Engine) []*Finding {
290
+ return p.runCycleOver(engine, p.userSourceFiles())
291
+ }
292
+
293
+ // runWriteScopedCycle walks the project's own sources alone. It serves the
294
+ // commands that edit files and report nothing: `format` and the LSP document
295
+ // fix and format verbs.
296
+ //
297
+ // Such a command must not rewrite a sibling package it merely imports, and it
298
+ // prints no diagnostic, so a finding outside the project has nowhere to go.
299
+ // Reading wider would spend a full walk, once per cascade pass, on findings the
300
+ // command discards. Scope is enforced by what these commands read rather than
301
+ // by filtering afterwards, which leaves projectWritableFindings to `fix` alone.
302
+ func (p *program) runWriteScopedCycle(engine *Engine) []*Finding {
303
+ return p.runCycleOver(engine, p.projectSourceFiles())
304
+ }
305
+
306
+ // runCycleOver evaluates the project rules and the file rules over one file set,
307
+ // memoizing the project cycle on the program so a second verb against the same
308
+ // program does not re-evaluate a rule. The caller owns the scope decision.
309
+ //
310
+ // That memo makes the scope a property of the program, not of the call: the
311
+ // first cycle fixes the population every later verb observes. One loaded
312
+ // program therefore serves one scope, and a caller must not ask the same
313
+ // program for both a lint cycle and a write-scoped one.
314
+ func (p *program) runCycleOver(engine *Engine, files []*shimast.SourceFile) []*Finding {
278
315
  if p == nil || engine == nil {
279
316
  return nil
280
317
  }
281
- files := p.userSourceFiles()
282
318
  if p.projectCycle == nil {
283
319
  p.projectCycle = engine.evaluateProject(p.identity, files, p.checker)
284
320
  }
@@ -348,19 +384,59 @@ func (p *program) applyChange(absPath string) bool {
348
384
  return reused
349
385
  }
350
386
 
351
- // userSourceFiles returns the tsconfig-selected source files the lint engine
352
- // owns. The tsconfig file list is the boundary: imported libraries, generated
353
- // output, and JSON modules may still appear in Program.SourceFiles(), but lint
354
- // and format should not walk them unless the project selected them as TS/JS
355
- // source roots.
387
+ // userSourceFiles returns the source files the lint engine reads for one cycle:
388
+ // the tsconfig-selected TS/JS roots plus every TypeScript source the Program
389
+ // pulled in through an import.
390
+ //
391
+ // The tsconfig file list alone is not the boundary. `ttsc` type-checks a
392
+ // first-party sibling workspace package that resolves to its own `src`, so a
393
+ // reporting pass restricted to the file list would hold a second, narrower view
394
+ // of the single Program the invocation loaded — the file is checked but never
395
+ // linted, and never reaches a project rule's ctx.Sources (samchon/ttsc#1065).
396
+ // A consumer cannot close that gap from configuration either: adding the
397
+ // sibling to `include` also changes what the project emits.
398
+ //
399
+ // The widening admits authored TypeScript only — `.ts`, `.tsx`, `.mts`, `.cts`
400
+ // that are not declaration files. Everything else stays selection-driven:
401
+ // - a declaration file is typings rather than authored source, and the bundled
402
+ // `lib.*.d.ts` set plus every published package's `.d.ts` reach
403
+ // Program.SourceFiles() as well;
404
+ // - JavaScript enters the Program only under `allowJs`, where the project's own
405
+ // file list already selects the JS it owns;
406
+ // - a JSON module carries no lint source at all.
407
+ //
408
+ // A project that selects any of those explicitly keeps them, exactly as before.
409
+ // A published dependency reaches the Program through its typings, so its own
410
+ // `.ts` sources stay out without a dependency-shaped rule here.
356
411
  func (p *program) userSourceFiles() []*shimast.SourceFile {
357
- roots := p.userSourceFileNames()
412
+ roots := p.projectSourceFileNames()
358
413
  out := make([]*shimast.SourceFile, 0)
359
414
  for _, f := range p.tsProgram.SourceFiles() {
360
415
  if f == nil {
361
416
  continue
362
417
  }
363
- if _, ok := roots[canonicalProjectPath(p.cwd, f.FileName())]; !ok {
418
+ if p.selectedByProject(roots, f.FileName()) {
419
+ out = append(out, f)
420
+ continue
421
+ }
422
+ if isImportedLintSourceFile(f) {
423
+ out = append(out, f)
424
+ }
425
+ }
426
+ return out
427
+ }
428
+
429
+ // projectSourceFiles returns the Program's copy of the files the tsconfig itself
430
+ // selected — the project's own sources, the set `format` walks and the set any
431
+ // lint write stays inside.
432
+ func (p *program) projectSourceFiles() []*shimast.SourceFile {
433
+ roots := p.projectSourceFileNames()
434
+ out := make([]*shimast.SourceFile, 0, len(roots))
435
+ for _, f := range p.tsProgram.SourceFiles() {
436
+ if f == nil {
437
+ continue
438
+ }
439
+ if !p.selectedByProject(roots, f.FileName()) {
364
440
  continue
365
441
  }
366
442
  out = append(out, f)
@@ -368,15 +444,88 @@ func (p *program) userSourceFiles() []*shimast.SourceFile {
368
444
  return out
369
445
  }
370
446
 
371
- func (p *program) userSourceFileNames() map[string]struct{} {
447
+ // projectSourceFileNames returns the canonical paths of the TS/JS files the
448
+ // tsconfig itself selected, indexed under both the configured spelling and the
449
+ // resolved one.
450
+ //
451
+ // This is the narrow half of the boundary above. `format` reads nothing else at
452
+ // all, and `fix` reads wider but writes only here, because a project must not
453
+ // rewrite a sibling package's sources merely because it imports them. See
454
+ // projectWritableFindings.
455
+ //
456
+ // Both spellings are indexed because a project can be reached through a
457
+ // junction, a symlink, or a Windows 8.3 short name, and the Program need not
458
+ // report a file under the spelling the config used. Before the read scope
459
+ // widened, an alias mismatch merely dropped the file from every pass. Now it
460
+ // would leave the file readable and unwritable, turning a fixable diagnostic
461
+ // into one `fix` refuses to touch, so ownership resolves the alias.
462
+ func (p *program) projectSourceFileNames() map[string]struct{} {
463
+ if p == nil {
464
+ return map[string]struct{}{}
465
+ }
466
+ if p.projectRoots != nil {
467
+ return p.projectRoots
468
+ }
372
469
  out := make(map[string]struct{})
373
- if p == nil || p.parsed == nil || p.parsed.ParsedConfig == nil {
374
- return out
470
+ if p.parsed != nil && p.parsed.ParsedConfig != nil {
471
+ for _, fileName := range p.parsed.ParsedConfig.FileNames {
472
+ if !isLintSourceFileName(fileName) {
473
+ continue
474
+ }
475
+ absolute := absoluteProjectPath(p.cwd, fileName)
476
+ out[canonicalProjectPath(p.cwd, absolute)] = struct{}{}
477
+ out[canonicalProjectPath(p.cwd, realProjectPath(absolute))] = struct{}{}
478
+ }
479
+ }
480
+ p.projectRoots = out
481
+ return out
482
+ }
483
+
484
+ // selectedByProject reports whether the tsconfig selected fileName, resolving
485
+ // the path only when its own spelling misses.
486
+ //
487
+ // Indexing both spellings above already catches the ordinary link, so this
488
+ // fallback exists for a Program spelling that matches neither, such as a
489
+ // Windows 8.3 short name. It costs one resolution per file the config did not
490
+ // select, paid by the imported set on every cycle, against the rule walk those
491
+ // same files are about to receive.
492
+ func (p *program) selectedByProject(
493
+ roots map[string]struct{},
494
+ fileName string,
495
+ ) bool {
496
+ if p == nil || len(roots) == 0 {
497
+ return false
375
498
  }
376
- for _, fileName := range p.parsed.ParsedConfig.FileNames {
377
- if isLintSourceFileName(fileName) {
378
- out[canonicalProjectPath(p.cwd, fileName)] = struct{}{}
499
+ if _, ok := roots[canonicalProjectPath(p.cwd, fileName)]; ok {
500
+ return true
501
+ }
502
+ resolved := realProjectPath(absoluteProjectPath(p.cwd, fileName))
503
+ _, ok := roots[canonicalProjectPath(p.cwd, resolved)]
504
+ return ok
505
+ }
506
+
507
+ // projectWritableFindings keeps the findings whose file this project may write:
508
+ // the ones sitting in a tsconfig-selected source, dropping every edit aimed at a
509
+ // source the Program reached only through an import.
510
+ //
511
+ // This is `fix`'s guard alone, because `fix` is the one command that must read
512
+ // wider than it writes: it prints the diagnostics that survive the cascade, so
513
+ // an imported source has to reach its report while its edit must not reach
514
+ // disk. The package that owns the file fixes it from its own run, under its own
515
+ // config. Commands that only write walk the narrow set to begin with. A finding
516
+ // without a source file (a project rule's detached report) never reaches disk
517
+ // either and is dropped with them.
518
+ func (p *program) projectWritableFindings(findings []*Finding) []*Finding {
519
+ roots := p.projectSourceFileNames()
520
+ out := make([]*Finding, 0, len(findings))
521
+ for _, finding := range findings {
522
+ if finding == nil || finding.File == nil {
523
+ continue
379
524
  }
525
+ if !p.selectedByProject(roots, finding.File.FileName()) {
526
+ continue
527
+ }
528
+ out = append(out, finding)
380
529
  }
381
530
  return out
382
531
  }
@@ -388,6 +537,18 @@ func canonicalProjectPath(cwd, fileName string) string {
388
537
  return filepath.ToSlash(filepath.Clean(fileName))
389
538
  }
390
539
 
540
+ // isImportedLintSourceFile reports whether a Program source file the tsconfig
541
+ // did not select is authored TypeScript the lint pass must still read.
542
+ func isImportedLintSourceFile(file *shimast.SourceFile) bool {
543
+ if file == nil || file.IsDeclarationFile {
544
+ return false
545
+ }
546
+ return isTypeScriptSourceFileName(file.FileName())
547
+ }
548
+
549
+ // isLintSourceFileName reports whether a tsconfig-selected file is a lint/format
550
+ // source root. The project's own selection governs here, so both TypeScript and
551
+ // JavaScript qualify: a project that lists `.js` under `allowJs` owns it.
391
552
  func isLintSourceFileName(fileName string) bool {
392
553
  switch strings.ToLower(filepath.Ext(fileName)) {
393
554
  case ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs":
@@ -397,6 +558,18 @@ func isLintSourceFileName(fileName string) bool {
397
558
  }
398
559
  }
399
560
 
561
+ // isTypeScriptSourceFileName reports whether a path names TypeScript source.
562
+ // `.d.ts` shares the `.ts` extension, so callers pair this with the source
563
+ // file's IsDeclarationFile flag rather than reading the suffix twice.
564
+ func isTypeScriptSourceFileName(fileName string) bool {
565
+ switch strings.ToLower(filepath.Ext(fileName)) {
566
+ case ".ts", ".tsx", ".mts", ".cts":
567
+ return true
568
+ default:
569
+ return false
570
+ }
571
+ }
572
+
400
573
  // programDiagnostics returns the bind + semantic diagnostics for the
401
574
  // loaded program. Same surface tsgo's CLI prints when you run a regular
402
575
  // `tsgo --noEmit`.
package/linthost/lsp.go CHANGED
@@ -981,7 +981,11 @@ func lspWorkspaceEditForSeededCommand(
981
981
  prog.close()
982
982
  return nil, 0
983
983
  }
984
- findings := filterFindingsForPath(prog.runLintCycle(engine), tempTarget)
984
+ // This command edits a document, so it walks the project's own sources the
985
+ // way `format` does. Reading the imported TypeScript the lint cycle covers
986
+ // would widen nothing here: the edit is bounded to one target below, and a
987
+ // read-scope widening must not open a write the project never had.
988
+ findings := filterFindingsForPath(prog.runWriteScopedCycle(engine), tempTarget)
985
989
  prog.close()
986
990
  if opts.command == commandFormatDocument {
987
991
  findings = filterFormatFindings(findings)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.23.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.23.0"
40
+ "ttsc": "0.25.0"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
package/rule/project.go CHANGED
@@ -170,7 +170,18 @@ type ProjectReporter interface {
170
170
  }
171
171
 
172
172
  // ProjectContext contains the immutable inputs for one project-rule check.
173
- // Sources is a defensive copy of the Program's tsconfig-selected user sources.
173
+ //
174
+ // Sources is a defensive copy of the user sources the host read for this cycle:
175
+ // the project's own tsconfig-selected files plus every TypeScript source the
176
+ // Program pulled in through an import, minus globally ignored paths. A rule
177
+ // that declares a population by glob therefore sees a first-party sibling
178
+ // workspace package the same way the type-check pass does, instead of an empty
179
+ // population that would silently report full coverage.
180
+ //
181
+ // A format run is the exception. It writes files and reports nothing, so it
182
+ // walks the project's own file list alone and a rule evaluated there receives
183
+ // that narrower population. Draw a conclusion that must hold across the
184
+ // workspace from a lint or check run.
174
185
  type ProjectContext struct {
175
186
  Identity ProjectIdentity
176
187
  Sources []*shimast.SourceFile
package/src/index.ts CHANGED
@@ -8,9 +8,7 @@ import path from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
10
  import {
11
- CONFIG_EVALUATOR_PROCESS_OPTIONS,
12
- CONFIG_EVALUATOR_STATUS_FD,
13
- configEvaluatorBoundaryEnvironment,
11
+ configEvaluatorFailureReason,
14
12
  configEvaluatorProcessFailure,
15
13
  } from "./internal/configEvaluatorFailure";
16
14
  import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures";
@@ -630,33 +628,68 @@ const hooks = registerHooks({
630
628
  },
631
629
  });
632
630
 
633
- try {
634
- const importedConfig = configLocation.toLowerCase().endsWith(".json")
635
- ? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, ""))
636
- : await import(configUrl);
637
- const current = await resolveConfig(importedConfig, true);
638
- const pluginMaps = collectPluginObjects(current);
639
- const entries: Array<{ namespace: string; source: string }> = [];
640
- for (const map of pluginMaps) {
641
- for (const [namespace, value] of Object.entries(map)) {
642
- const source = extractPluginSource(value);
643
- if (source === undefined || source.length === 0) {
644
- throw new Error(
645
- \`contributor \${JSON.stringify(namespace)} must resolve to an object with a non-empty "source" string\`,
646
- );
631
+ // Wrapped and settled explicitly rather than written as a top-level await.
632
+ // The loader tsconfig's "module" now follows the config's own package, and
633
+ // TS1378 rejects top-level await under a CommonJS module option however this
634
+ // .mts file emits. The trailing catch is what a top-level await gave for
635
+ // free: without it a throw from the finally would leave the promise
636
+ // unsettled instead of failing the load.
637
+ (async () => {
638
+ try {
639
+ const importedConfig = configLocation.toLowerCase().endsWith(".json")
640
+ ? JSON.parse(fs.readFileSync(configLocation, "utf8").replace(/^\uFEFF/, ""))
641
+ : await import(configUrl);
642
+ const current = await resolveConfig(importedConfig, true);
643
+ const pluginMaps = collectPluginObjects(current);
644
+ const entries: Array<{ namespace: string; source: string }> = [];
645
+ for (const map of pluginMaps) {
646
+ for (const [namespace, value] of Object.entries(map)) {
647
+ const source = extractPluginSource(value);
648
+ if (source === undefined || source.length === 0) {
649
+ throw new Error(
650
+ \`contributor \${JSON.stringify(namespace)} must resolve to an object with a non-empty "source" string\`,
651
+ );
652
+ }
653
+ entries.push({ namespace, source });
647
654
  }
648
- entries.push({ namespace, source });
649
655
  }
656
+ fs.writeFileSync(outputPath, JSON.stringify({
657
+ dependencies: finalizeDependencies(),
658
+ entries,
659
+ }), "utf8");
660
+ } catch (error) {
661
+ reportLoaderFailure(error);
662
+ } finally {
663
+ hooks.deregister();
650
664
  }
651
- fs.writeFileSync(outputPath, JSON.stringify({
652
- dependencies: finalizeDependencies(),
653
- entries,
654
- }), "utf8");
655
- } catch (error) {
656
- process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
657
- process.exit(1);
658
- } finally {
659
- hooks.deregister();
665
+ })().catch((error) => {
666
+ // Reached only when the finally above throws: the catch already ends the
667
+ // process, so this is the deregistration's own failure, not the config's.
668
+ reportLoaderFailure(error);
669
+ });
670
+
671
+ // reportLoaderFailure ends this loader on an error it can name, on both of the
672
+ // channels the parent uses.
673
+ //
674
+ // The stack is for a reader and streams to stderr as it is written. The reason
675
+ // is a fact about the user's config that a caller has to act on, so it travels
676
+ // as data through the result file the parent already reads. Only a well-formed
677
+ // envelope is honoured there, so a partially written or unrelated file leaves
678
+ // the process status to speak for itself.
679
+ function reportLoaderFailure(error: unknown): never {
680
+ // The trailing newline ends the stack as a line of its own. Without it the
681
+ // parent's own message, written to this same stream, starts mid-line.
682
+ process.stderr.write((error instanceof Error && error.stack ? error.stack : String(error)) + "\\n");
683
+ try {
684
+ fs.writeFileSync(
685
+ outputPath,
686
+ JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }),
687
+ "utf8",
688
+ );
689
+ } catch {
690
+ // A reason that cannot be written leaves the exit status as the report.
691
+ }
692
+ return process.exit(1);
660
693
  }
661
694
 
662
695
  function isObject(value: unknown): value is Record<string, unknown> {
@@ -1702,7 +1735,13 @@ function evaluateTtsxConfigPlugins(
1702
1735
  allowImportingTsExtensions: true,
1703
1736
  allowJs: true,
1704
1737
  checkJs: false,
1705
- module: "ESNext",
1738
+ // The config is a Node module, so Node's rule decides its format:
1739
+ // the nearest `package.json` `type` above it. Hardcoding one answer
1740
+ // ran every ambiguous `.ts` config as ESM and broke `__dirname` in
1741
+ // an ordinary CommonJS package (#1068). `moduleResolution` stays
1742
+ // `bundler`, which tsgo accepts for both kinds, so extensionless
1743
+ // relative imports keep resolving either way.
1744
+ module: configModuleOption(configPath),
1706
1745
  moduleResolution: "bundler",
1707
1746
  noImplicitAny: false,
1708
1747
  outDir: path.join(tempDir, "out").replace(/\\/g, "/"),
@@ -1711,6 +1750,12 @@ function evaluateTtsxConfigPlugins(
1711
1750
  skipLibCheck: true,
1712
1751
  strict: false,
1713
1752
  target: "ES2022",
1753
+ // TypeScript 7 includes no ambient type package unless `types` asks
1754
+ // for it, and this Program extends nothing, so without the wildcard
1755
+ // a config could not name a single Node global (#1068). The loader
1756
+ // directory links the config's nearest `node_modules`, so the
1757
+ // default `typeRoots` walk finds exactly what the project installed.
1758
+ types: ["*"],
1714
1759
  },
1715
1760
  files: [
1716
1761
  loaderPath.replace(/\\/g, "/"),
@@ -1749,28 +1794,29 @@ function evaluateTtsxConfigPlugins(
1749
1794
  }
1750
1795
  const env = {
1751
1796
  ...nodeConfigLoaderEnv(configPath),
1752
- ...configEvaluatorBoundaryEnvironment(),
1753
1797
  };
1754
1798
  const command = ttsxThroughNodeIfNeeded(ttsxBinary);
1755
1799
  const result = spawnSync(command.binary, [...command.prefix, ...args], {
1756
1800
  cwd: tempDir,
1757
1801
  env,
1758
- encoding: "utf8",
1759
- ...CONFIG_EVALUATOR_PROCESS_OPTIONS,
1760
- stdio: [
1761
- "ignore",
1762
- "pipe",
1763
- "pipe",
1764
- ...Array.from(
1765
- { length: CONFIG_EVALUATOR_STATUS_FD - 2 },
1766
- () => "pipe" as const,
1767
- ),
1768
- ],
1802
+ // Both child streams are human output, and they go straight to this
1803
+ // process's stderr as they are written. Nothing is collected here: the
1804
+ // parent's stdout is reserved for compiler JSON and LSP frames, and
1805
+ // buffering the child only to replay it afterwards is what forced an
1806
+ // invented output ceiling and made a long evaluation print nothing at all.
1807
+ stdio: ["ignore", 2, 2],
1769
1808
  windowsHide: true,
1770
1809
  });
1771
- forwardConfigEvaluatorStreams(result.stdout, result.stderr);
1772
1810
  const processFailure = configEvaluatorProcessFailure(result, configPath);
1773
- if (processFailure) throw processFailure;
1811
+ if (processFailure) {
1812
+ // The evaluator's stack already reached the user's stderr as it ran. What
1813
+ // it could not put there is a reason a caller can act on, so that arrives
1814
+ // through the result file instead.
1815
+ const reason = configEvaluatorFailureReason(outputPath);
1816
+ throw reason === ""
1817
+ ? processFailure
1818
+ : new Error(`${processFailure.message}\n${reason}`);
1819
+ }
1774
1820
  let payload: {
1775
1821
  dependencies?: ConfigDependencyFingerprint[];
1776
1822
  entries?: ConfigPluginEntry[];
@@ -1841,20 +1887,10 @@ function evaluateTtsxConfigPlugins(
1841
1887
  }
1842
1888
  return { dependencies, entries };
1843
1889
  } finally {
1844
- fs.rmSync(tempDir, { recursive: true, force: true });
1890
+ removeEvaluationTempDir(tempDir);
1845
1891
  }
1846
1892
  }
1847
1893
 
1848
- function forwardConfigEvaluatorStreams(
1849
- stdout: string | null | undefined,
1850
- stderr: string | null | undefined,
1851
- ): void {
1852
- // Both child streams are human output. Parent stdout is reserved for compiler
1853
- // JSON or LSP frames, so even a user console.log is redirected.
1854
- if (stdout) process.stderr.write(stdout);
1855
- if (stderr) process.stderr.write(stderr);
1856
- }
1857
-
1858
1894
  // ────────────────────────────────────────────────────────────────────────────
1859
1895
  // Config cache (shared with the Go sidecar — packages/lint/linthost/config.go)
1860
1896
  // ────────────────────────────────────────────────────────────────────────────
@@ -2268,6 +2304,64 @@ function findNearestNodeModules(start: string): string | undefined {
2268
2304
  }
2269
2305
  }
2270
2306
 
2307
+ /**
2308
+ * The loader tsconfig's `module` for a given config file: the module kind Node
2309
+ * would give the file itself.
2310
+ *
2311
+ * An explicit `.cts`/`.cjs` or `.mts`/`.mjs` extension already decides the emit
2312
+ * format on its own, so those keep the ES-module setting and let the extension
2313
+ * win — the same precedence tsgo applies. Everything ambiguous walks up for the
2314
+ * nearest `package.json` `type`, exactly as Node does when it loads the file.
2315
+ */
2316
+ function configModuleOption(configPath: string): string {
2317
+ const extension = path.extname(configPath).toLowerCase();
2318
+ if (extension !== ".ts" && extension !== ".tsx" && extension !== ".js") {
2319
+ return "ESNext";
2320
+ }
2321
+ return nearestPackageType(configPath) === "commonjs" ? "CommonJS" : "ESNext";
2322
+ }
2323
+
2324
+ /**
2325
+ * Nearest `package.json` `"type"` at or above `configPath`, mirroring Node's
2326
+ * package-scope lookup: the walk stops at the **first** manifest it finds, and
2327
+ * a manifest that declares no `"type"` means CommonJS rather than a reason to
2328
+ * keep climbing. Reaching the filesystem root without any manifest also means
2329
+ * CommonJS.
2330
+ */
2331
+ function nearestPackageType(configPath: string): "commonjs" | "module" {
2332
+ let dir = path.dirname(path.resolve(configPath));
2333
+ for (;;) {
2334
+ // One read rather than a stat-then-read: an unreadable entry and a missing
2335
+ // one are the same answer here — keep walking — and the Go loaders resolve
2336
+ // it the same way, so the four implementations of this rule cannot drift.
2337
+ let raw: string | undefined;
2338
+ try {
2339
+ raw = fs.readFileSync(path.join(dir, "package.json"), "utf8");
2340
+ } catch {
2341
+ raw = undefined;
2342
+ }
2343
+ if (raw !== undefined) {
2344
+ try {
2345
+ const manifest: unknown = JSON.parse(raw);
2346
+ const type =
2347
+ typeof manifest === "object" && manifest !== null
2348
+ ? (manifest as { type?: unknown }).type
2349
+ : undefined;
2350
+ return type === "module" ? "module" : "commonjs";
2351
+ } catch {
2352
+ // A manifest that does not parse still bounds the package scope; Node
2353
+ // refuses to look past it, and CommonJS is the format it defaults to.
2354
+ return "commonjs";
2355
+ }
2356
+ }
2357
+ const parent = path.dirname(dir);
2358
+ if (parent === dir) {
2359
+ return "commonjs";
2360
+ }
2361
+ dir = parent;
2362
+ }
2363
+ }
2364
+
2271
2365
  function linkNearestNodeModules(tempDir: string, sourceDir: string): void {
2272
2366
  const nodeModules = findNearestNodeModules(sourceDir);
2273
2367
  if (!nodeModules) return;
@@ -2400,3 +2494,19 @@ function ttsxThroughNodeIfNeeded(binary: string): {
2400
2494
  }
2401
2495
  return { binary, prefix: [] };
2402
2496
  }
2497
+
2498
+ /**
2499
+ * Remove an evaluation temp directory without letting cleanup replace a result.
2500
+ *
2501
+ * This runs from a `finally`, so a throw here would surface instead of the
2502
+ * evaluation's own outcome — and on Windows a grandchild that inherited a
2503
+ * handle, or a scanner holding the file, can make removal fail. Leaving bytes
2504
+ * in the system temp directory is by far the lesser outcome.
2505
+ */
2506
+ function removeEvaluationTempDir(directory: string): void {
2507
+ try {
2508
+ fs.rmSync(directory, { force: true, recursive: true });
2509
+ } catch {
2510
+ // Best effort.
2511
+ }
2512
+ }