Repository navigation
Description
🔎 Search Terms
createSnapshot, openProjects, extends, circular extends, TS18000, compilerOptions, strict, extendedConfigCache, ExtendedSourceFiles, resolution stack, cache hit
🕗 Version & Regression Information
- This is the behavior in every version I tried, with the synchronous API (
typescript/sync) on linux-x64:[email protected](native binary@typescript/[email protected], built frommainat 042e0fb), where the reproduction losesstrictin 3 of 3 runs, and[email protected](native binary@typescript/[email protected], built frommainat 6161772).
⏯ 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