Skip to content

tsgo --build incremental reports no errors after a dependency update batch that includes a global-scope .d.ts change (clean build errors) #4664

Description

@martijnwalraven

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:

import "dep-b";

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:

  1. tsgo --b — clean initial build, exit 0, writes dist/tsconfig.tsbuildinfo.
  2. Apply one update batch (the shape of a package-manager dependency bump):
    • node_modules/dep-a/index.d.tsexport 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)
  3. 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:

  1. a lone global-scope dependency content change fails to re-check files that
    use the global but don't import the changed module, and
  2. 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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions