Skip to content

Commit ece352f

Browse files
authored
TS 5.0 Supporting Multiple Configuration Files in extends (#1958)
* extract tsconfig tests to new spec file; add failing test for "extends" from multiple configs * Add support for `"extends": []` extending from multiple configs * Fix tests * fix test * fix test on windows * fmt * fix
1 parent 1c5857c commit ece352f

File tree

12 files changed

+283
-150
lines changed

12 files changed

+283
-150
lines changed

src/configuration.ts

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export function readConfig(
154154
}> = [];
155155
let config: any = { compilerOptions: {} };
156156
let basePath = cwd;
157-
let configFilePath: string | undefined = undefined;
157+
let rootConfigPath: string | undefined = undefined;
158158
const projectSearchDir = resolve(cwd, rawApiOptions.projectSearchDir ?? cwd);
159159

160160
const {
@@ -170,62 +170,80 @@ export function readConfig(
170170
if (project) {
171171
const resolved = resolve(cwd, project);
172172
const nested = join(resolved, 'tsconfig.json');
173-
configFilePath = fileExists(nested) ? nested : resolved;
173+
rootConfigPath = fileExists(nested) ? nested : resolved;
174174
} else {
175-
configFilePath = ts.findConfigFile(projectSearchDir, fileExists);
175+
rootConfigPath = ts.findConfigFile(projectSearchDir, fileExists);
176176
}
177177

178-
if (configFilePath) {
179-
let pathToNextConfigInChain = configFilePath;
178+
if (rootConfigPath) {
179+
// If root extends [a, c] and a extends b, c extends d, then this array will look like:
180+
// [root, c, d, a, b]
181+
let configPaths = [rootConfigPath];
180182
const tsInternals = createTsInternals(ts);
181183
const errors: Array<_ts.Diagnostic> = [];
182184

183185
// Follow chain of "extends"
184-
while (true) {
185-
const result = ts.readConfigFile(pathToNextConfigInChain, readFile);
186+
for (
187+
let configPathIndex = 0;
188+
configPathIndex < configPaths.length;
189+
configPathIndex++
190+
) {
191+
const configPath = configPaths[configPathIndex];
192+
const result = ts.readConfigFile(configPath, readFile);
186193

187194
// Return diagnostics.
188195
if (result.error) {
189196
return {
190-
configFilePath,
197+
configFilePath: rootConfigPath,
191198
config: { errors: [result.error], fileNames: [], options: {} },
192199
tsNodeOptionsFromTsconfig: {},
193200
optionBasePaths: {},
194201
};
195202
}
196203

197204
const c = result.config;
198-
const bp = dirname(pathToNextConfigInChain);
205+
const bp = dirname(configPath);
199206
configChain.push({
200207
config: c,
201208
basePath: bp,
202-
configPath: pathToNextConfigInChain,
209+
configPath: configPath,
203210
});
204211

205-
if (c.extends == null) break;
206-
const resolvedExtendedConfigPath = tsInternals.getExtendsConfigPath(
207-
c.extends,
208-
{
209-
fileExists,
210-
readDirectory: ts.sys.readDirectory,
211-
readFile,
212-
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
213-
trace: tsTrace,
214-
},
215-
bp,
216-
errors,
217-
(ts as unknown as TSInternal).createCompilerDiagnostic
218-
);
219-
if (errors.length) {
220-
return {
221-
configFilePath,
222-
config: { errors, fileNames: [], options: {} },
223-
tsNodeOptionsFromTsconfig: {},
224-
optionBasePaths: {},
225-
};
212+
if (c.extends == null) continue;
213+
const extendsArray = Array.isArray(c.extends) ? c.extends : [c.extends];
214+
for (const e of extendsArray) {
215+
const resolvedExtendedConfigPath = tsInternals.getExtendsConfigPath(
216+
e,
217+
{
218+
fileExists,
219+
readDirectory: ts.sys.readDirectory,
220+
readFile,
221+
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
222+
trace: tsTrace,
223+
},
224+
bp,
225+
errors,
226+
(ts as unknown as TSInternal).createCompilerDiagnostic
227+
);
228+
if (errors.length) {
229+
return {
230+
configFilePath: rootConfigPath,
231+
config: { errors, fileNames: [], options: {} },
232+
tsNodeOptionsFromTsconfig: {},
233+
optionBasePaths: {},
234+
};
235+
}
236+
if (resolvedExtendedConfigPath != null) {
237+
// Tricky! If "extends" array is [a, c] then this will splice them into this order:
238+
// [root, c, a]
239+
// This is what we want.
240+
configPaths.splice(
241+
configPathIndex + 1,
242+
0,
243+
resolvedExtendedConfigPath
244+
);
245+
}
226246
}
227-
if (resolvedExtendedConfigPath == null) break;
228-
pathToNextConfigInChain = resolvedExtendedConfigPath;
229247
}
230248

231249
({ config, basePath } = configChain[0]);
@@ -277,7 +295,7 @@ export function readConfig(
277295
rawApiOptions.files ?? tsNodeOptionsFromTsconfig.files ?? DEFAULTS.files;
278296

279297
// Only if a config file is *not* loaded, load an implicit configuration from @tsconfig/bases
280-
const skipDefaultCompilerOptions = configFilePath != null;
298+
const skipDefaultCompilerOptions = rootConfigPath != null;
281299
const defaultCompilerOptionsForNodeVersion = skipDefaultCompilerOptions
282300
? undefined
283301
: {
@@ -316,12 +334,12 @@ export function readConfig(
316334
},
317335
basePath,
318336
undefined,
319-
configFilePath
337+
rootConfigPath
320338
)
321339
);
322340

323341
return {
324-
configFilePath,
342+
configFilePath: rootConfigPath,
325343
config: fixedConfig,
326344
tsNodeOptionsFromTsconfig,
327345
optionBasePaths,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { BIN_PATH } from '../helpers/paths';
2+
import { createExec } from '../exec-helpers';
3+
import { TEST_DIR } from '../helpers/paths';
4+
import { context, expect } from '../testlib';
5+
import { join, resolve } from 'path';
6+
import { tsSupportsExtendsArray } from '../helpers/version-checks';
7+
import { ctxTsNode } from '../helpers';
8+
9+
const test = context(ctxTsNode);
10+
11+
const exec = createExec({
12+
cwd: TEST_DIR,
13+
});
14+
15+
test.suite('should read ts-node options from tsconfig.json', (test) => {
16+
const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`;
17+
18+
test('should override compiler options from env', async () => {
19+
const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, {
20+
env: {
21+
...process.env,
22+
TS_NODE_COMPILER_OPTIONS: '{"typeRoots": ["env-typeroots"]}',
23+
},
24+
});
25+
expect(r.err).toBe(null);
26+
const { config } = JSON.parse(r.stdout);
27+
expect(config.options.typeRoots).toEqual([
28+
join(TEST_DIR, './tsconfig-options/env-typeroots').replace(/\\/g, '/'),
29+
]);
30+
});
31+
32+
test('should use options from `tsconfig.json`', async () => {
33+
const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`);
34+
expect(r.err).toBe(null);
35+
const { options, config } = JSON.parse(r.stdout);
36+
expect(config.options.typeRoots).toEqual([
37+
join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace(
38+
/\\/g,
39+
'/'
40+
),
41+
]);
42+
expect(config.options.types).toEqual(['tsconfig-tsnode-types']);
43+
expect(options.pretty).toBe(undefined);
44+
expect(options.skipIgnore).toBe(false);
45+
expect(options.transpileOnly).toBe(true);
46+
expect(options.require).toEqual([
47+
join(TEST_DIR, './tsconfig-options/required1.js'),
48+
]);
49+
});
50+
51+
test('should ignore empty strings in the array options', async () => {
52+
const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, {
53+
env: {
54+
...process.env,
55+
TS_NODE_IGNORE: '',
56+
},
57+
});
58+
expect(r.err).toBe(null);
59+
const { options } = JSON.parse(r.stdout);
60+
expect(options.ignore).toEqual([]);
61+
});
62+
63+
test('should have flags override / merge with `tsconfig.json`', async () => {
64+
const r = await exec(
65+
`${BIN_EXEC} --skip-ignore --compiler-options "{\\"types\\":[\\"flags-types\\"]}" --require ./tsconfig-options/required2.js tsconfig-options/log-options2.js`
66+
);
67+
expect(r.err).toBe(null);
68+
const { options, config } = JSON.parse(r.stdout);
69+
expect(config.options.typeRoots).toEqual([
70+
join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace(
71+
/\\/g,
72+
'/'
73+
),
74+
]);
75+
expect(config.options.types).toEqual(['flags-types']);
76+
expect(options.pretty).toBe(undefined);
77+
expect(options.skipIgnore).toBe(true);
78+
expect(options.transpileOnly).toBe(true);
79+
expect(options.require).toEqual([
80+
join(TEST_DIR, './tsconfig-options/required1.js'),
81+
'./tsconfig-options/required2.js',
82+
]);
83+
});
84+
85+
test('should have `tsconfig.json` override environment', async () => {
86+
const r = await exec(`${BIN_EXEC} tsconfig-options/log-options1.js`, {
87+
env: {
88+
...process.env,
89+
TS_NODE_PRETTY: 'true',
90+
TS_NODE_SKIP_IGNORE: 'true',
91+
},
92+
});
93+
expect(r.err).toBe(null);
94+
const { options, config } = JSON.parse(r.stdout);
95+
expect(config.options.typeRoots).toEqual([
96+
join(TEST_DIR, './tsconfig-options/tsconfig-typeroots').replace(
97+
/\\/g,
98+
'/'
99+
),
100+
]);
101+
expect(config.options.types).toEqual(['tsconfig-tsnode-types']);
102+
expect(options.pretty).toBe(true);
103+
expect(options.skipIgnore).toBe(false);
104+
expect(options.transpileOnly).toBe(true);
105+
expect(options.require).toEqual([
106+
join(TEST_DIR, './tsconfig-options/required1.js'),
107+
]);
108+
});
109+
110+
test('should pull ts-node options from extended `tsconfig.json`', async () => {
111+
const r = await exec(
112+
`${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json`
113+
);
114+
expect(r.err).toBe(null);
115+
const config = JSON.parse(r.stdout);
116+
expect(config['ts-node'].require).toEqual([
117+
resolve(TEST_DIR, 'tsconfig-extends/other/require-hook.js'),
118+
]);
119+
expect(config['ts-node'].scopeDir).toBe(
120+
resolve(TEST_DIR, 'tsconfig-extends/other/scopedir')
121+
);
122+
expect(config['ts-node'].preferTsExts).toBe(true);
123+
});
124+
125+
test.suite(
126+
'should pull ts-node options from extended `tsconfig.json`',
127+
(test) => {
128+
test.if(tsSupportsExtendsArray);
129+
test('test', async () => {
130+
const r = await exec(
131+
`${BIN_PATH} --show-config --project ./tsconfig-extends-multiple/tsconfig.json`
132+
);
133+
expect(r.err).toBe(null);
134+
const config = JSON.parse(r.stdout);
135+
136+
// root tsconfig extends [a, c]
137+
// a extends b
138+
// c extends d
139+
140+
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#supporting-multiple-configuration-files-in-extends
141+
// If any fields "conflict", the latter entry wins.
142+
143+
// This value comes from c
144+
expect(config.compilerOptions.target).toBe('es2017');
145+
146+
// From root
147+
expect(config['ts-node'].preferTsExts).toBe(true);
148+
149+
// From a
150+
expect(config['ts-node'].require).toEqual([
151+
resolve(
152+
TEST_DIR,
153+
'tsconfig-extends-multiple/a/require-hook-from-a.js'
154+
),
155+
]);
156+
157+
// From a, overrides declaration in b
158+
expect(config['ts-node'].scopeDir).toBe(
159+
resolve(TEST_DIR, 'tsconfig-extends-multiple/a/scopedir-from-a')
160+
);
161+
162+
// From b
163+
const key =
164+
process.platform === 'win32'
165+
? 'b\\module-types-from-b'
166+
: 'b/module-types-from-b';
167+
expect(config['ts-node'].moduleTypes).toStrictEqual({
168+
[key]: 'cjs',
169+
});
170+
171+
// From c, overrides declaration in b
172+
expect(config['ts-node'].transpiler).toBe('transpiler-from-c');
173+
174+
// From d, inherited by c, overrides value from b
175+
expect(config['ts-node'].ignore).toStrictEqual([
176+
'ignore-pattern-from-d',
177+
]);
178+
});
179+
}
180+
);
181+
});

src/test/helpers/version-checks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export const tsSupportsAllowImportingTsExtensions = semver.gte(
3838
ts.version,
3939
'4.999.999'
4040
);
41+
// TS 5.0 adds ability for tsconfig to `"extends": []` an array of configs
42+
export const tsSupportsExtendsArray = semver.gte(ts.version, '4.999.999');
4143
// Relevant when @tsconfig/bases refers to es2021 and we run tests against
4244
// old TS versions.
4345
export const tsSupportsEs2021 = semver.gte(ts.version, '4.3.0');

0 commit comments

Comments
 (0)