Skip to content

x verify's typecheck step can report green locally while CI is red, on the same commit (unreproduced) #450

Description

@ivndev001

Observed in a scaffolded 20.1.0 app (private, ivndev001/dz-showcase): a commit with a genuine
type error (an island props interface whose index signature included undefined, not assignable
to JsonValue) passed bin/check's typecheck step locally and failed the same step in CI, on
the same commit. Control on the SAME tree: rm tsconfig.tsbuildinfo then x verify --only typecheck → red. So the gitignored incremental tsconfig.tsbuildinfo made the step report green
over a tree that does not typecheck — a violation of this repo's axiom 5, "green must mean the
same thing for everyone": CI (no buildinfo, always cold) and a local run (warm) disagreed on the
identical commit.

What the step actually runs

packages/cli/src/verify-checks.ts's typecheck step: bunx tsc -b --pretty false at the repo
root, no --force, matching verify-step.ts's and cmd-verify.ts's own description of the step.
A scaffolded app's tsconfig (packages/cli/src/templates/scaffold-repo.ts's rootTsconfig) is a
single flat project — "incremental": true, no composite, no references — pinned to
"typescript": "^7.0.2" (TYPESCRIPT_VERSION, same file). tsconfig.tsbuildinfo is written next
to tsconfig.json (default location, no tsBuildInfoFile override) and is in the scaffold's
.gitignore.

What I tried to reproduce it with, and could not

Working hypothesis going in: tsc -b's incremental mode is either (a) not truly content-hash
sound (some fast-path trusts file mtime over content) or (b) persists a stale semantic-diagnostics
verdict across a run that never re-examined a changed file. Both are real, version-dependent
behaviours of TypeScript's build-mode incremental checker historically.

Tested directly against this repo's own pinned typescript@^7.0.2 (confirmed via
bunx tsc --versionVersion 7.0.2, the "TypeScript 7" native rewrite):

  1. Unchanged-tree replay. tsc -b on an erroring single project, then tsc -b again with
    nothing touched at all. Stayed red both times — diagnostics are persisted in
    semanticDiagnosticsPerFile inside .tsbuildinfo and re-reported on an up-to-date project, not
    dropped.
  2. Mtime backdating. touch -d '2000-01-01' on the erroring file (content unchanged from the
    run that recorded it), then touch -d it again with different bad content but an OLD mtime
    relative to .tsbuildinfo's own mtime. Still red — the recorded fileInfos[].version is a
    content hash, not an mtime, and tsc -b reads and hashes the file regardless of mtime ordering
    in this version.
  3. The exact git stash / checkout / checkout back / stash pop sequence the working
    tree's prior history actually had: clean baseline committed and typechecked (green, writes
    .tsbuildinfo); dirty edit introduces the type error; git stash (never typechecked in this
    state); an unrelated commit on the feature branch; git checkout to another branch and back;
    git stash pop restores the bad edit. First tsc -b to ever see this exact bad tree: red.
    Repeated with git stash genuinely reverting mtimes to a fresh "now" (git rewrites mtime on
    checkout/stash pop for every file whose content it writes — I could not get a scenario
    where the erroring file's mtime was OLDER than .tsbuildinfo's own mtime as a result of these
    operations; mtimes only moved forward).
  4. The same sequence across a real composite, multi-project reference graph (root tsconfig
    with no files, referencing two composite sub-projects, one importing the other, matching the
    shape this framework's OWN root tsconfig.json uses with its 32 references — a different
    shape from a scaffolded app's flat one, tried in case the bug needs project boundaries). Error
    introduced in the upstream project via the same stash/checkout/pop sequence. Still red, both on
    first sight and on a repeat run with nothing changed.
  5. Deleted-then-restored file, with different content than either version tsc had seen before.
    Deleted the imported file (correctly reported TS2307), then recreated it with new bad content
    under the same path. Still red.

All five runs — cold or warm, single-project or multi-project, mtime-manipulated or not — correctly
caught the type error. I did not find a way to make tsc -b @7.0.2 report green over a tree with a
real type error using the mechanisms named in the task brief (project references, mtime games via
git, a deleted/restored file). I did not test: a node_modules type change (no vector for that
in a workspace-symlinked monorepo with skipLibCheck: true, and none in a scaffolded app either,
short of a bun install that changes a @types/* version — untried, lower priority since it is
not implicated by anything in the original report), and a genuinely concurrent second tsc
process racing the same .tsbuildinfo (e.g. an editor's TS server, or x dev's own typecheck,
writing to the same file x verify's one-shot run reads) — I have no way to drive that
deterministically in this sandbox, and the original report does not mention a second process
running.

What this leaves

  • Either the original failure depended on a detail of the dz-showcase working tree or its exact
    operation sequence that I have not reconstructed (a concurrent writer is my leading unexcluded
    suspect — it is the one class of cause that does not require tsc's content-hash check itself
    to be unsound, only for two processes to interleave writes to one file), or
  • TypeScript 7.0.2's incremental build is sound against everything above and the defect needs a
    detail from the actual dz-showcase tree (exact tsconfig, exact git history, exact TypeScript
    version actually installed there at the time — bun.lock can pin a different resolved version
    than the ^7.0.2 range if the app's lockfile is older) that a synthetic repro cannot supply
    without that tree.

I'm not shipping a mitigation (e.g. --force on the gate's typecheck step, or a "cold typecheck
in CI-parity mode") against this: I have no reproduction to justify the cost, and axiom 5's
argument needs a demonstrated failure, not a hypothetical one. Filing this so the investigation
(and the five ruled-out mechanisms) aren't lost, and so anyone who CAN reproduce it starts from
here instead of from the same five dead ends. If it recurs, the next data point that would move
this forward is the dz-showcase tree's actual resolved typescript version at the time
(bun.lock), and whether x dev or an editor TS server was running against the same app root
concurrently with the bin/check run that came back green.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions