Skip to content

Commit 4f5ddae

Browse files
authored
Match Strada's global diagnostic collection, hide non-diag global errors in editor (#64452)
1 parent ebb89d1 commit 4f5ddae

17 files changed

Lines changed: 2285 additions & 30 deletions

‎tsc/internal/checker/checker.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14200,6 +14200,7 @@ func (c *Checker) getDiagnostics(ctx context.Context, sourceFile *ast.SourceFile
1420014200

1420114201
func (c *Checker) GetGlobalDiagnostics() []*ast.Diagnostic {
1420214202
c.checkNotCanceled()
14203+
c.produceDeferredDiagnostics()
1420314204
return c.diagnostics.GetGlobalDiagnostics()
1420414205
}
1420514206

‎tsc/internal/compiler/contentmapper_test.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,65 @@ func TestContentMapperVirtualExtensionSetsImpliedNodeFormat(t *testing.T) {
9191
assert.Equal(t, program.GetSourceFileMetaData(file.Path()).ImpliedNodeFormat, core.ResolutionModeESM)
9292
}
9393

94+
func TestContentMapperDirectivesPreserveIncrementalGlobals(t *testing.T) {
95+
t.Parallel()
96+
for _, policy := range []ast.MappedDiagnosticDirectivePolicy{
97+
ast.MappedDiagnosticDirectivePolicyIgnore,
98+
ast.MappedDiagnosticDirectivePolicyExpect,
99+
} {
100+
t.Run(core.IfElse(policy == ast.MappedDiagnosticDirectivePolicyExpect, "expect", "ignore"), func(t *testing.T) {
101+
t.Parallel()
102+
const text = "export function values() { function* generator() { yield 1; } }"
103+
program := newContentMapperProgramWithOptions(t, fakeContentMapperHost{
104+
transform: func(fileName string, content string) (contentmapper.Result, error) {
105+
// The virtual source is unchanged; the directive covers the entire file, including offset zero.
106+
return contentmapper.Result{
107+
Text: content,
108+
VirtualExtension: ".ts",
109+
Mappings: spanmap.New([]spanmap.Segment{{
110+
OriginalEnd: core.TextPos(len(content)),
111+
VirtualEnd: core.TextPos(len(content)),
112+
Kind: spanmap.KindVerbatim,
113+
Features: spanmap.FeatureAll,
114+
}}),
115+
DiagnosticDirectives: []ast.MappedDiagnosticDirective{{
116+
VirtualRange: core.NewTextRange(0, len(content)),
117+
OriginalRange: core.NewTextRange(0, len(content)),
118+
Policy: policy,
119+
Source: "vue",
120+
UnusedCode: 2578,
121+
UnusedMessageText: "Unused mapped expect directive.",
122+
}},
123+
}, nil
124+
},
125+
}, map[string]string{"/src/Component.vue": text}, []string{"/src/Component.vue"}, &core.CompilerOptions{
126+
Lib: []string{"lib.es5.d.ts"},
127+
SkipLibCheck: core.TSTrue,
128+
Module: core.ModuleKindESNext,
129+
ModuleResolution: core.ModuleResolutionKindBundler,
130+
})
131+
assert.Equal(t, len(program.GetGlobalDiagnostics(t.Context())), 0)
132+
file := program.GetSourceFile("/src/Component.vue")
133+
diags := program.GetSemanticDiagnosticsForIncremental(t.Context(), []*ast.SourceFile{file})[file]
134+
var globals, unused int
135+
for _, diag := range diags {
136+
if diag.File() == nil {
137+
assert.Equal(t, diag.Code(), diagnostics.Cannot_find_global_type_0.Code())
138+
assert.Equal(t, diag.MessageArgs()[0], "IterableIterator")
139+
globals++
140+
} else {
141+
assert.Equal(t, diag.File(), file)
142+
assert.Equal(t, diag.Source(), "vue")
143+
assert.Equal(t, diag.Code(), int32(2578))
144+
unused++
145+
}
146+
}
147+
assert.Equal(t, globals, 1)
148+
assert.Equal(t, unused, core.IfElse(policy == ast.MappedDiagnosticDirectivePolicyExpect, 1, 0))
149+
})
150+
}
151+
}
152+
94153
func TestCompositeProjectContentMapperSupplementalRoots(t *testing.T) {
95154
t.Parallel()
96155
contentMapperHost := fakeContentMapperHost{transform: func(fileName string, content string) (contentmapper.Result, error) {

‎tsc/internal/compiler/program.go‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -799,8 +799,12 @@ func (p *Program) GetSemanticDiagnostics(ctx context.Context, sourceFile *ast.So
799799
return p.collectCheckerDiagnostics(ctx, sourceFile, p.getSemanticDiagnosticsWithChecker)
800800
}
801801

802-
func (p *Program) GetSemanticDiagnosticsWithoutNoEmitFiltering(ctx context.Context, sourceFiles []*ast.SourceFile) map[*ast.SourceFile][]*ast.Diagnostic {
803-
allDiags := p.collectCheckerDiagnosticsFromFiles(ctx, sourceFiles, p.getBindAndCheckDiagnosticsWithChecker)
802+
// GetSemanticDiagnosticsForIncremental includes newly discovered globals in each
803+
// file's cached diagnostics and leaves noEmit filtering to the builder.
804+
func (p *Program) GetSemanticDiagnosticsForIncremental(ctx context.Context, sourceFiles []*ast.SourceFile) map[*ast.SourceFile][]*ast.Diagnostic {
805+
allDiags := p.collectCheckerDiagnosticsFromFiles(ctx, sourceFiles, func(ctx context.Context, c *checker.Checker, file *ast.SourceFile) []*ast.Diagnostic {
806+
return p.getBindAndCheckDiagnosticsWithChecker(ctx, c, file, true /*includeDeferredGlobals*/)
807+
})
804808
result := make(map[*ast.SourceFile][]*ast.Diagnostic, len(sourceFiles))
805809
for i, diags := range allDiags {
806810
result[sourceFiles[i]] = filterAndSortDiagnostics(diags)
@@ -1475,24 +1479,42 @@ func FilterNoEmitSemanticDiagnostics(diagnostics []*ast.Diagnostic, options *cor
14751479

14761480
func (p *Program) getSemanticDiagnosticsWithChecker(ctx context.Context, c *checker.Checker, sourceFile *ast.SourceFile) []*ast.Diagnostic {
14771481
return core.Concatenate(
1478-
FilterNoEmitSemanticDiagnostics(p.getBindAndCheckDiagnosticsWithChecker(ctx, c, sourceFile), p.Options()),
1482+
FilterNoEmitSemanticDiagnostics(p.getBindAndCheckDiagnosticsWithChecker(ctx, c, sourceFile, false /*includeDeferredGlobals*/), p.Options()),
14791483
p.GetIncludeProcessorDiagnostics(sourceFile),
14801484
)
14811485
}
14821486

14831487
// getBindAndCheckDiagnosticsWithChecker gets semantic diagnostics for a single file using a
14841488
// caller-provided checker, including bind diagnostics, checker diagnostics, and handling
14851489
// of @ts-ignore/@ts-expect-error directives.
1486-
func (p *Program) getBindAndCheckDiagnosticsWithChecker(ctx context.Context, fileChecker *checker.Checker, sourceFile *ast.SourceFile) []*ast.Diagnostic {
1490+
func (p *Program) getBindAndCheckDiagnosticsWithChecker(ctx context.Context, fileChecker *checker.Checker, sourceFile *ast.SourceFile, includeDeferredGlobals bool) []*ast.Diagnostic {
14871491
compilerOptions := p.Options()
14881492
if p.SkipTypeChecking(sourceFile, false) {
14891493
return nil
14901494
}
1495+
var previousGlobals []*ast.Diagnostic
1496+
if includeDeferredGlobals {
1497+
previousGlobals = fileChecker.GetGlobalDiagnostics()
1498+
}
14911499

14921500
// Checker creation forces binding, so bind diagnostics will be populated.
14931501
diags := slices.Clip(sourceFile.BindDiagnostics())
14941502
diags = append(diags, fileChecker.GetDiagnostics(ctx, sourceFile)...)
14951503

1504+
if includeDeferredGlobals {
1505+
if fileChecker.WasCanceled() {
1506+
return nil
1507+
}
1508+
currentGlobals := fileChecker.GetGlobalDiagnostics()
1509+
if len(currentGlobals) > len(previousGlobals) {
1510+
for _, diagnostic := range currentGlobals {
1511+
if _, found := slices.BinarySearchFunc(previousGlobals, diagnostic, ast.CompareDiagnostics); !found {
1512+
diags = append(diags, diagnostic)
1513+
}
1514+
}
1515+
}
1516+
}
1517+
14961518
isPlainJS := ast.IsPlainJSFile(sourceFile, compilerOptions.CheckJs)
14971519
if isPlainJS {
14981520
return core.Filter(diags, func(d *ast.Diagnostic) bool {
@@ -1525,7 +1547,7 @@ func applyContentMapperDiagnosticDirectives(sourceFile *ast.SourceFile, diags []
15251547
}
15261548
used := make([]bool, len(directives))
15271549
markUsed := func(diag *ast.Diagnostic) bool {
1528-
if diag.Source() != "" {
1550+
if diag.File() != sourceFile || diag.Source() != "" {
15291551
return false
15301552
}
15311553
for i, directive := range directives {
@@ -1568,6 +1590,10 @@ func (p *Program) getDiagnosticsWithPrecedingDirectives(sourceFile *ast.SourceFi
15681590
filtered := make([]*ast.Diagnostic, 0, len(diags))
15691591
for _, diagnostic := range diags {
15701592
ignoreDiagnostic := false
1593+
if diagnostic.File() != sourceFile {
1594+
filtered = append(filtered, diagnostic)
1595+
continue
1596+
}
15711597
for line := scanner.ComputeLineOfPosition(lineStarts, diagnostic.Pos()) - 1; line >= 0; line-- {
15721598
// If line contains a @ts-ignore or @ts-expect-error directive, ignore this diagnostic and change
15731599
// the directive kind to @ts-ignore to indicate it was used.
@@ -2023,8 +2049,11 @@ func GetDiagnosticsOfAnyProgram(
20232049

20242050
if len(allDiagnostics) == configFileParsingDiagnosticsLength {
20252051
allDiagnostics = appendDiagnosticsForAllFiles(allDiagnostics, getSemanticDiagnostics)
2026-
// Ask for the global diagnostics again (they were empty above); we may have found new during checking, e.g. missing globals.
2027-
allDiagnostics = append(allDiagnostics, program.GetGlobalDiagnostics(ctx)...)
2052+
if p, ok := program.(*Program); ok {
2053+
// Incremental programs cache checking globals with file diagnostics;
2054+
// a late sweep would also collect incidental signature-generation globals.
2055+
allDiagnostics = append(allDiagnostics, p.GetGlobalDiagnostics(ctx)...)
2056+
}
20282057
}
20292058

20302059
if (skipNoEmitCheckForDtsDiagnostics || program.Options().NoEmit.IsTrue()) && program.Options().GetEmitDeclarations() && len(allDiagnostics) == configFileParsingDiagnosticsLength {

‎tsc/internal/execute/incremental/program.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ func (p *Program) collectSemanticDiagnosticsOfAffectedFiles(ctx context.Context,
303303
}
304304

305305
// Get their diagnostics and cache them
306-
diagnosticsPerFile := p.program.GetSemanticDiagnosticsWithoutNoEmitFiltering(ctx, affectedFiles)
306+
diagnosticsPerFile := p.program.GetSemanticDiagnosticsForIncremental(ctx, affectedFiles)
307307
// commit changes if no err
308308
if ctx.Err() != nil {
309309
return

‎tsc/internal/execute/tsctests/tsc_test.go‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ func TestTscCommandline(t *testing.T) {
2626
}
2727
}
2828
testCases := []*tscInput{
29+
{
30+
subScenario: "global diagnostics produced during ordinary semantic checking",
31+
files: FileMap{
32+
"/home/src/workspaces/project/index.ts": `export function* values() { yield 1; }`,
33+
},
34+
commandLineArgs: []string{"index.ts", "--noEmit"},
35+
},
2936
{
3037
subScenario: "show help with ExitStatus.DiagnosticsPresent_OutputsSkipped",
3138
env: map[string]string{
@@ -1349,6 +1356,33 @@ func TestTscIgnoreConfig(t *testing.T) {
13491356

13501357
func TestTscIncremental(t *testing.T) {
13511358
t.Parallel()
1359+
libWithReadonlyArray := strings.Replace(tscDefaultLibContent, "interface ReadonlyArray<T> {}", "interface ReadonlyArray<T> { readonly length: number; readonly [n: number]: T; }", 1)
1360+
getRecursiveTypeTest := func(name string, source string) *tscInput {
1361+
return &tscInput{
1362+
subScenario: name + " after comment only edit",
1363+
files: FileMap{
1364+
"/home/src/workspaces/project/tsconfig.json": `{"compilerOptions": {"strict": true, "noEmit": true, "incremental": true}}`,
1365+
"/home/src/workspaces/project/repro.ts": stringtestutil.Dedent(source),
1366+
tscLibPath + "/lib.es2025.full.d.ts": libWithReadonlyArray,
1367+
},
1368+
edits: []*tscEdit{
1369+
{
1370+
caption: "add a comment",
1371+
edit: func(sys *TestSys) {
1372+
sys.appendFile("/home/src/workspaces/project/repro.ts", "\n// comment-only edit\n")
1373+
},
1374+
},
1375+
noChange,
1376+
{
1377+
caption: "add another comment",
1378+
edit: func(sys *TestSys) {
1379+
sys.appendFile("/home/src/workspaces/project/repro.ts", "\n// another comment\n")
1380+
},
1381+
},
1382+
noChange,
1383+
},
1384+
}
1385+
}
13521386
getConstEnumTest := func(bdsContents string, changeEnumFile string, testSuffix string) *tscInput {
13531387
return &tscInput{
13541388
subScenario: "const enums" + testSuffix,
@@ -2270,6 +2304,85 @@ func TestTscIncremental(t *testing.T) {
22702304
},
22712305
commandLineArgs: []string{"--noEmit"},
22722306
},
2307+
getRecursiveTypeTest("recursive mapped type", `
2308+
type Json = string | Json[];
2309+
type Parsed<T> = T extends object ? { [K in keyof T]: Parsed<T[K]> } : T;
2310+
declare function wrap<T>(value: T): Parsed<T>;
2311+
export const value = wrap({ items: [] as Json[] });
2312+
`),
2313+
getRecursiveTypeTest("recursive readonly mapped type", `
2314+
type Json = string | readonly Json[];
2315+
type Parsed<T> = T extends object ? { [K in keyof T]: Parsed<T[K]> } : T;
2316+
declare function wrap<T>(value: T): Parsed<T>;
2317+
export const value = wrap({ items: [] as readonly Json[] });
2318+
`),
2319+
{
2320+
subScenario: "global diagnostics produced during semantic checking",
2321+
files: FileMap{
2322+
"/home/src/workspaces/project/tsconfig.json": `{"compilerOptions": {"noEmit": true, "incremental": true}}`,
2323+
"/home/src/workspaces/project/repro.ts": `export function* values() { yield 1; }`,
2324+
},
2325+
edits: []*tscEdit{
2326+
noChange,
2327+
{
2328+
caption: "add a comment",
2329+
edit: func(sys *TestSys) {
2330+
sys.appendFile("/home/src/workspaces/project/repro.ts", "\n// comment-only edit\n")
2331+
},
2332+
expectedDiff: "Like Strada, signature generation produces the missing-global diagnostic before semantic checking, so it is excluded from the file's semantic diagnostics.",
2333+
},
2334+
{
2335+
caption: "no change",
2336+
edit: noChange.edit,
2337+
expectedDiff: "Like Strada, the cached semantic diagnostics do not include the missing-global diagnostic produced during signature generation.",
2338+
},
2339+
{
2340+
caption: "delete build info to restore the semantic diagnostic",
2341+
edit: func(sys *TestSys) {
2342+
sys.removeNoError("/home/src/workspaces/project/tsconfig.tsbuildinfo")
2343+
},
2344+
},
2345+
},
2346+
},
2347+
{
2348+
subScenario: "global diagnostics from function bodies after incremental edits",
2349+
files: FileMap{
2350+
"/home/src/workspaces/project/tsconfig.json": `{"compilerOptions": {"noEmit": true, "incremental": true}}`,
2351+
"/home/src/workspaces/project/repro.ts": stringtestutil.Dedent(`
2352+
export function values() {
2353+
// @ts-ignore
2354+
function* generator() { yield 1; }
2355+
}
2356+
`),
2357+
},
2358+
edits: []*tscEdit{
2359+
noChange,
2360+
{
2361+
caption: "add a comment",
2362+
edit: func(sys *TestSys) {
2363+
sys.appendFile("/home/src/workspaces/project/repro.ts", "\n// comment-only edit\n")
2364+
},
2365+
},
2366+
noChange,
2367+
},
2368+
},
2369+
{
2370+
subScenario: "global diagnostics produced during unchecked javascript checking",
2371+
files: FileMap{
2372+
"/home/src/workspaces/project/tsconfig.json": `{"compilerOptions": {"allowJs": true, "noEmit": true, "incremental": true}}`,
2373+
"/home/src/workspaces/project/repro.js": `export function* values() { yield 1; }`,
2374+
},
2375+
edits: []*tscEdit{
2376+
noChange,
2377+
{
2378+
caption: "enable javascript checking",
2379+
edit: func(sys *TestSys) {
2380+
sys.replaceFileText("/home/src/workspaces/project/tsconfig.json", `"allowJs": true`, `"allowJs": true, "checkJs": true`)
2381+
},
2382+
},
2383+
noChange,
2384+
},
2385+
},
22732386
{
22742387
subScenario: "json module diagnostics are cleared after fixing the json file",
22752388
files: FileMap{

‎tsc/internal/project/checkerpool.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,10 @@ func (p *checkerPool) createRelease(requestID string, index int, c *checker.Chec
351351
p.log(fmt.Sprintf("checkerpool: Checker %d for request %s was canceled, disposing", index, holdTag(requestID)))
352352
p.disposeCheckerLocked(index, c)
353353
} else {
354-
p.mergeGlobalDiagnosticsFromCheckerLocked(index, c)
354+
// Query checkers can produce incidental errors while serializing types.
355+
if index == 0 {
356+
p.mergeGlobalDiagnosticsFromCheckerLocked(index, c)
357+
}
355358
p.heldBy[index] = ""
356359
p.lastReleased[index] = time.Now()
357360
if !p.discarded {
@@ -491,8 +494,8 @@ func (p *checkerPool) mergeGlobalDiagnosticsFromCheckerLocked(index int, c *chec
491494
}
492495
}
493496

494-
// GetGlobalDiagnostics returns the accumulated global diagnostics collected from
495-
// all checkers that have been used so far in this pool's lifetime.
497+
// GetGlobalDiagnostics returns the global diagnostics accumulated from the dedicated
498+
// diagnostics checker across its instances during this pool's lifetime.
496499
func (p *checkerPool) GetGlobalDiagnostics() []*ast.Diagnostic {
497500
p.mu.Lock()
498501
defer p.mu.Unlock()

‎tsc/internal/project/checkerpool_test.go‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,31 +1157,26 @@ func TestCheckerPoolTakeNewGlobalDiagnostics(t *testing.T) {
11571157

11581158
// Use a checker and trigger diagnostics, then release to run the merge.
11591159
ctx := core.WithRequestID(context.Background(), "global-diag-req")
1160-
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeTemporary)
1160+
ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics)
11611161
sourceFile := pool.program.GetSourceFile("/src/index.ts")
11621162
c, release := pool.GetChecker(ctx, sourceFile)
11631163
assert.Assert(t, c != nil)
11641164
c.GetDiagnostics(ctx, sourceFile)
11651165
release()
11661166

1167-
// Whether globals were produced depends on the program, but the flag
1168-
// should reflect the merge result.
1169-
firstTake := pool.TakeNewGlobalDiagnostics()
1167+
assert.Assert(t, pool.TakeNewGlobalDiagnostics(), "diagnostics checker should publish missing-lib globals")
11701168

11711169
// After taking, a second call should always return false (flag is reset).
11721170
assert.Assert(t, !pool.TakeNewGlobalDiagnostics(), "TakeNewGlobalDiagnostics should reset after first call")
11731171

11741172
// Releasing the same checker again with the same state should not set the flag.
11751173
ctx2 := core.WithRequestID(context.Background(), "global-diag-req-2")
1176-
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeTemporary)
1174+
ctx2 = core.WithCheckerLifetime(ctx2, core.CheckerLifetimeDiagnostics)
11771175
c2, release2 := pool.GetChecker(ctx2, sourceFile)
11781176
assert.Assert(t, c2 != nil)
11791177
c2.GetDiagnostics(ctx2, sourceFile)
11801178
release2()
11811179

1182-
// If first call produced globals, the count is now stable, so no new change.
1183-
// If first call produced no globals, still no change.
1184-
_ = firstTake
11851180
assert.Assert(t, !pool.TakeNewGlobalDiagnostics(), "should not report new globals when checker state is unchanged")
11861181
}
11871182

0 commit comments

Comments
 (0)