Skip to content

[api] createSnapshot drops options a project inherits through a circular extends chain #65301

Open

Description

🔎 Search Terms

createSnapshot, openProjects, extends, circular extends, TS18000, compilerOptions, strict, extendedConfigCache, ExtendedSourceFiles, resolution stack, cache hit

🕗 Version & Regression Information

⏯ Playground Link

The API is not in the playground, so the reproduction below runs it from Node.

💻 Code

package.json:

{ "private": true, "type": "module", "dependencies": { "typescript": "7.1.0-dev.20261103.1" } }

repro.mjs writes shared/base.json, which extends shared/a.json and shared/strict.json, shared/a.json, which extends shared/base.json again, and shared/strict.json, which sets strict. p1/tsconfig.json extends base.json and p2/tsconfig.json extends a.json. It opens p2 alone in one API session, then p1 and p2 in another, and prints strict from p2's compiler options:

import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { API } from "typescript/sync";

const dir = join(process.cwd(), "proj");
rmSync(dir, { recursive: true, force: true });
for (const name of ["shared", "p1", "p2"]) mkdirSync(join(dir, name), { recursive: true });
writeFileSync(join(dir, "shared", "base.json"), JSON.stringify({ extends: ["./a.json", "./strict.json"] }));
writeFileSync(join(dir, "shared", "a.json"), JSON.stringify({ extends: "./base.json" }));
writeFileSync(join(dir, "shared", "strict.json"), JSON.stringify({ compilerOptions: { strict: true } }));
writeFileSync(join(dir, "p1", "tsconfig.json"), JSON.stringify({ extends: "../shared/base.json" }));
writeFileSync(join(dir, "p1", "a.ts"), "export const a = 1;\n");
writeFileSync(join(dir, "p2", "tsconfig.json"), JSON.stringify({ extends: "../shared/a.json" }));
writeFileSync(join(dir, "p2", "b.ts"), "export const b = 1;\n");

const p2 = join(dir, "p2", "tsconfig.json");
function show(label, snapshot) {
  const project = snapshot.getProjects().find((p) => p.configFileName === p2);
  console.log(`${label}: strict=${project.program.getCompilerOptions().strict}`);
}

let api = new API({ cwd: dir });
show("p2 alone", api.createSnapshot({ openProjects: [p2] }));
api.close();

api = new API({ cwd: dir });
api.createSnapshot({ openProjects: [join(dir, "p1", "tsconfig.json")] });
show("p1 then p2", api.createSnapshot({ openProjects: [p2] }));
api.close();

🙁 Actual behavior

p2 has strict when it is opened alone, and loses it when p1 was opened first, in 3 of 3 runs:

$ node repro.mjs
p2 alone: strict=true
p1 then p2: strict=undefined
$ cd proj && npx tsc -p p2 --showConfig
{
    "compilerOptions": {
        "strict": true
    },
    "files": [
        "./b.ts"
    ]
}

🙂 Expected behavior

p2 has the same compiler options whichever project was opened first, with strict set, as tsc -p p2 --showConfig reports.

Additional information about the issue

Impact: in a repository with a circular extends chain, a project that extends into the cycle can get the wrong compiler options and config file list on the API, depending on which project was opened first, with no error beyond the TS18000 that tsc -p also reports, so an analyzer reads that project with options tsc -p does not use.

Opening p1 parses base.json and then a.json through the extended config cache with base.json on the resolution stack, so getExtendedConfig parses base.json without the cache and parseConfig stops there with TS18000. The cached a.json entry therefore has none of what base.json brings in: neither strict nor strict.json. Opening p2 loads a.json with only p2/tsconfig.json on the stack, where a fresh parse follows base.json and strict.json, but the cache entry's hash covers only the file contents, which are unchanged, so isExpired returns false and p2 gets the entry built under p1's stack.

tsc -b loses options the same way, because its own extended config cache also keys entries by path alone. With "compilerOptions": { "noUnusedLocals": true } added to a.json and an unused local in p1, tsc -b p1 p2 --singleThreaded reports no TS6133 for p1 in 3 of 3 runs, while tsc -b p1 --singleThreaded and tsc -p p1 report it. Without --singleThreaded the same build hangs, which is #64833.

#64824 is a panic on the same kind of tree with a different cause.

I met this while building deadset-ts, the TypeScript analyzer of deadset, on the TypeScript 7 API.

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

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions