Skip to content
This repository was archived by the owner on Sep 1, 2026. It is now read-only.
This repository was archived by the owner on Sep 1, 2026. It is now read-only.

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

Description

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.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)
  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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions