Steps to reproduce
Project layout (composite, strict; src/consumer.ts reaches dep-a only
through src/middle.ts, whose declaration signature does not change across
the update; dep-b's .d.ts contains a declare global augmentation, so it
affectsGlobalScope):
project/
tsconfig.json
src/consumer.ts
src/middle.ts
src/env.ts
node_modules/dep-a/package.json
node_modules/dep-a/index.d.ts
node_modules/dep-b/package.json
node_modules/dep-b/index.d.ts
tsconfig.json:
{
"compilerOptions": {
"composite": true,
"outDir": "dist",
"strict": true
},
"include": ["src/**/*"]
}
src/consumer.ts:
import type { Kind } from "./middle";
export function describe(kind: Kind): string {
switch (kind) {
case "a":
return "first";
case "b":
return "second";
}
}
src/middle.ts:
export type { Kind } from "dep-a";
src/env.ts:
node_modules/dep-a/package.json:
{ "name": "dep-a", "version": "1.0.0", "types": "index.d.ts" }
node_modules/dep-a/index.d.ts:
export type Kind = "a" | "b";
node_modules/dep-b/package.json:
{ "name": "dep-b", "version": "1.0.0", "types": "index.d.ts" }
node_modules/dep-b/index.d.ts:
declare global {
interface DepBGlobal {
marker: string;
}
}
export {};
Steps:
tsgo --b — clean initial build, exit 0, writes dist/tsconfig.tsbuildinfo.
- Apply one update batch (the shape of a package-manager dependency bump):
node_modules/dep-a/index.d.ts → export type Kind = "a" | "b" | "c";
(breaks the exhaustive switch in src/consumer.ts)
node_modules/dep-b/index.d.ts → the DepBGlobal interface gains a
member (any content change to this global-scope file works; the change
itself is inert — nothing uses DepBGlobal)
- append a comment line to
src/env.ts (irrelevant to tsgo; included so
the identical steps also drive tsc 6.0's build-mode rebuild, see note
below)
tsgo --b again.
Behavior with typescript@6.0
(verified with 6.0.3) The incremental rebuild reports the error:
src/consumer.ts(2,39): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
exit code 1.
Note: step 2's src/env.ts edit is what makes tsc 6.0 --b rebuild at all —
its build-mode up-to-date check does not consider node_modules resolution
results as inputs (the limitation tsgo already fixed in #4301 via
packageJsons tracking). Without that edit 6.0 reports "Project ... is up to
date". The comparison here is about the layer below: once a rebuild runs,
6.0's incremental program invalidates correctly and tsgo's does not.
Behavior with tsgo
(verified on main @ b8276f3, 2026-07-16, and on typescript/v7.0.2) The
incremental rebuild exits 0 and reports nothing. The written
dist/tsconfig.tsbuildinfo records the new dependency contents with no
semanticDiagnosticsPerFile entry and no pending marker for
src/consumer.ts — since absence means fully-checked-clean to the buildinfo
reader, the false green is durable: every subsequent incremental build stays
green until src/consumer.ts itself (or something on its signature-connected
path) changes content.
A clean build of the same tree (rm -rf dist && tsgo --b) reports the
TS2366, exit 2.
Mechanism
When any changed file in an incremental batch affectsGlobalScope, tsgo
skips reverse-dependency semantic invalidation for the whole batch
(internal/execute/incremental/affectedfileshandler.go, line numbers on
main @ b8276f3):
getFilesAffectedBy (:110–146): for a global-scope changed file it sets
the handler-global hasAllFilesExcludingDefaultLibraryFile flag and primes
the all-files cache (:120–123) — but, unlike Strada, does not use the
all-files set as its result; it falls through to the normal reverse walk,
which stops at unchanged .d.ts signatures (:132–141). Strada's
getFilesAffectedByUpdatedShapeWhenModuleEmit (src/compiler/builderState.ts:596-597)
returns getAllFilesExcludingDefaultLibraryFile(...) here.
handleDtsMayChangeOfAffectedFile (:178–189): with that flag set, every
affected file takes an early return after clearing only its own and the
library files' diagnostics — the reverse-referencedBy invalidation
(:195–267) that removes dependents' cached-clean diagnostics runs for none
of the batch's changed files, global or not. Strada has the same early
return (src/compiler/builder.ts:811 runs removeSemanticDiagnosticsOf
for each affected file just before it, builder.ts:805–828), but there it is
safe because the affected set is all files. tsgo kept the early return and
dropped the all-files set.
So there are two facets:
- a lone global-scope dependency content change fails to re-check files that
use the global but don't import the changed module, and
- one global-scope file in a changed batch suppresses dependent invalidation
for every other changed file in that batch — the repro above: the
dep-a break is missed because dep-b changed alongside it (the control run
without the dep-b edit reports the error correctly).
The buildinfo writer then records the lie durably
(snapshottobuildinfo.go:320–338 emits no entry for cached-clean files;
buildinfotosnapshot.go:151–167 reads absence as diagnosed-clean).
Lineage
Field incident (how this was found)
A pnpm monorepo on 7.0.2 (tsc -b over a composite solution) took a routine
dependency batch in which one package's .d.ts gained union members (a real
TS2739 in a consumer's exhaustiveness check three reverse-hops away) while
other packages in the same batch shipped global-scope .d.ts changes
(declare global shims, @cloudflare/workers-types-style ambients). The
first post-bump build was a real rebuild that reported "ok"; the buildinfo
recorded the new dependency paths as fully-checked-clean, and the false green
survived rebuilds and re-invocations for five days until an unrelated
one-line edit to the consumer file surfaced the error.
Repro as tests
The three scenarios (batch with global-scope co-change, control without it,
lone global-scope content change) are implemented as tsbuild/dependencyUpdate
cases in internal/execute/tsctests/tscbuild_test.go; on unmodified main the
first and third fail the harness's incremental-vs-clean consistency check
with exactly the missing-diagnostic diff. See the accompanying PR.
Steps to reproduce
Project layout (composite, strict;
src/consumer.tsreachesdep-aonlythrough
src/middle.ts, whose declaration signature does not change acrossthe update;
dep-b's.d.tscontains adeclare globalaugmentation, so itaffectsGlobalScope):tsconfig.json:{ "compilerOptions": { "composite": true, "outDir": "dist", "strict": true }, "include": ["src/**/*"] }src/consumer.ts:src/middle.ts:src/env.ts:node_modules/dep-a/package.json:{ "name": "dep-a", "version": "1.0.0", "types": "index.d.ts" }node_modules/dep-a/index.d.ts:node_modules/dep-b/package.json:{ "name": "dep-b", "version": "1.0.0", "types": "index.d.ts" }node_modules/dep-b/index.d.ts:Steps:
tsgo --b— clean initial build, exit 0, writesdist/tsconfig.tsbuildinfo.node_modules/dep-a/index.d.ts→export type Kind = "a" | "b" | "c";(breaks the exhaustive switch in
src/consumer.ts)node_modules/dep-b/index.d.ts→ theDepBGlobalinterface gains amember (any content change to this global-scope file works; the change
itself is inert — nothing uses
DepBGlobal)src/env.ts(irrelevant to tsgo; included sothe identical steps also drive
tsc 6.0's build-mode rebuild, see notebelow)
tsgo --bagain.Behavior with
typescript@6.0(verified with 6.0.3) The incremental rebuild reports the error:
exit code 1.
Note: step 2's
src/env.tsedit is what makestsc 6.0 --brebuild at all —its build-mode up-to-date check does not consider
node_modulesresolutionresults as inputs (the limitation tsgo already fixed in #4301 via
packageJsonstracking). Without that edit 6.0 reports "Project ... is up todate". The comparison here is about the layer below: once a rebuild runs,
6.0's incremental program invalidates correctly and tsgo's does not.
Behavior with
tsgo(verified on main @ b8276f3, 2026-07-16, and on
typescript/v7.0.2) Theincremental rebuild exits 0 and reports nothing. The written
dist/tsconfig.tsbuildinforecords the new dependency contents with nosemanticDiagnosticsPerFileentry and no pending marker forsrc/consumer.ts— since absence means fully-checked-clean to the buildinforeader, the false green is durable: every subsequent incremental build stays
green until
src/consumer.tsitself (or something on its signature-connectedpath) changes content.
A clean build of the same tree (
rm -rf dist && tsgo --b) reports theTS2366, exit 2.
Mechanism
When any changed file in an incremental batch
affectsGlobalScope, tsgoskips reverse-dependency semantic invalidation for the whole batch
(
internal/execute/incremental/affectedfileshandler.go, line numbers onmain @ b8276f3):
getFilesAffectedBy(:110–146): for a global-scope changed file it setsthe handler-global
hasAllFilesExcludingDefaultLibraryFileflag and primesthe all-files cache (:120–123) — but, unlike Strada, does not use the
all-files set as its result; it falls through to the normal reverse walk,
which stops at unchanged .d.ts signatures (:132–141). Strada's
getFilesAffectedByUpdatedShapeWhenModuleEmit(src/compiler/builderState.ts:596-597)returns
getAllFilesExcludingDefaultLibraryFile(...)here.handleDtsMayChangeOfAffectedFile(:178–189): with that flag set, everyaffected file takes an early return after clearing only its own and the
library files' diagnostics — the reverse-
referencedByinvalidation(:195–267) that removes dependents' cached-clean diagnostics runs for none
of the batch's changed files, global or not. Strada has the same early
return (
src/compiler/builder.ts:811runsremoveSemanticDiagnosticsOffor each affected file just before it, builder.ts:805–828), but there it is
safe because the affected set is all files. tsgo kept the early return and
dropped the all-files set.
So there are two facets:
use the global but don't import the changed module, and
for every other changed file in that batch — the repro above: the
dep-a break is missed because dep-b changed alongside it (the control run
without the dep-b edit reports the error correctly).
The buildinfo writer then records the lie durably
(
snapshottobuildinfo.go:320–338emits no entry for cached-clean files;buildinfotosnapshot.go:151–167reads absence as diagnosed-clean).Lineage
typescript/v7.0.2with Use package.json locations for build up-to-date checks #4301's fix present (Use package.json locations for build up-to-date checks #4301,merged as 042a471, fixed the build-mode up-to-date check for dependency
updates — issue
tsgo --buildfails to invalidate incremental cache after dependency update #2666). Use package.json locations for build up-to-date checks #4301's baselines cover in-place .d.ts edits andpackage.json redirects where the consumer is signature-connected to the
changed file; they don't cover a global-scope co-change or a dependent
beyond an unchanged-signature hop, so this arm survived.
which is about signature-state persistence, a different subsystem; the
divergence here is the affected-files set itself.
Field incident (how this was found)
A pnpm monorepo on 7.0.2 (
tsc -bover a composite solution) took a routinedependency batch in which one package's .d.ts gained union members (a real
TS2739 in a consumer's exhaustiveness check three reverse-hops away) while
other packages in the same batch shipped global-scope .d.ts changes
(
declare globalshims,@cloudflare/workers-types-style ambients). Thefirst post-bump build was a real rebuild that reported "ok"; the buildinfo
recorded the new dependency paths as fully-checked-clean, and the false green
survived rebuilds and re-invocations for five days until an unrelated
one-line edit to the consumer file surfaced the error.
Repro as tests
The three scenarios (batch with global-scope co-change, control without it,
lone global-scope content change) are implemented as
tsbuild/dependencyUpdatecases in
internal/execute/tsctests/tscbuild_test.go; on unmodified main thefirst and third fail the harness's incremental-vs-clean consistency check
with exactly the missing-diagnostic diff. See the accompanying PR.