@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/README.md +4 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +161 -46
- package/lib/index.js.map +1 -1
- package/lib/internal/configEvaluatorFailure.d.ts +24 -20
- package/lib/internal/configEvaluatorFailure.js +41 -54
- package/lib/internal/configEvaluatorFailure.js.map +1 -1
- package/linthost/config.go +317 -78
- package/linthost/fix.go +7 -1
- package/linthost/format.go +3 -1
- package/linthost/host.go +187 -14
- package/linthost/lsp.go +5 -1
- package/package.json +2 -2
- package/rule/project.go +12 -1
- package/src/index.ts +163 -53
- package/src/internal/configEvaluatorFailure.ts +35 -64
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
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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:
|
|
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
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|