Skip to content

Commit 4c7e9b0

Browse files
fix: allow circular references in config (#18056)
(cherry picked from commit b577e8a) Co-authored-by: Francesco Trotta <github@fasttime.org>
1 parent 77dbfd9 commit 4c7e9b0

3 files changed

Lines changed: 321 additions & 8 deletions

File tree

lib/config/flat-config-schema.js

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,17 @@ function isUndefined(value) {
6262
return typeof value === "undefined";
6363
}
6464

65+
// A unique empty object to be used internally as a mapping key in `deepMerge`.
66+
const EMPTY_OBJECT = {};
67+
6568
/**
6669
* Deeply merges two objects.
6770
* @param {Object} first The base object.
68-
* @param {Object} second The overrides object.
71+
* @param {any} second The overrides value.
72+
* @param {Map<string, Map<string, Object>>} [mergeMap] Maps the combination of first and second arguments to a merged result.
6973
* @returns {Object} An object with properties from both first and second.
7074
*/
71-
function deepMerge(first = {}, second = {}) {
75+
function deepMerge(first, second = {}, mergeMap = new Map()) {
7276

7377
/*
7478
* If the second value is an array, just return it. We don't merge
@@ -78,8 +82,23 @@ function deepMerge(first = {}, second = {}) {
7882
return second;
7983
}
8084

85+
let secondMergeMap = mergeMap.get(first);
86+
87+
if (secondMergeMap) {
88+
const result = secondMergeMap.get(second);
89+
90+
if (result) {
91+
92+
// If this combination of first and second arguments has been already visited, return the previously created result.
93+
return result;
94+
}
95+
} else {
96+
secondMergeMap = new Map();
97+
mergeMap.set(first, secondMergeMap);
98+
}
99+
81100
/*
82-
* First create a result object where properties from the second object
101+
* First create a result object where properties from the second value
83102
* overwrite properties from the first. This sets up a baseline to use
84103
* later rather than needing to inspect and change every property
85104
* individually.
@@ -89,6 +108,9 @@ function deepMerge(first = {}, second = {}) {
89108
...second
90109
};
91110

111+
// Store the pending result for this combination of first and second arguments.
112+
secondMergeMap.set(second, result);
113+
92114
for (const key of Object.keys(second)) {
93115

94116
// avoid hairy edge case
@@ -100,13 +122,10 @@ function deepMerge(first = {}, second = {}) {
100122
const secondValue = second[key];
101123

102124
if (isNonNullObject(firstValue)) {
103-
result[key] = deepMerge(firstValue, secondValue);
125+
result[key] = deepMerge(firstValue, secondValue, mergeMap);
104126
} else if (isUndefined(firstValue)) {
105127
if (isNonNullObject(secondValue)) {
106-
result[key] = deepMerge(
107-
Array.isArray(secondValue) ? [] : {},
108-
secondValue
109-
);
128+
result[key] = deepMerge(EMPTY_OBJECT, secondValue, mergeMap);
110129
} else if (!isUndefined(secondValue)) {
111130
result[key] = secondValue;
112131
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* @fileoverview Tests for flatConfigSchema
3+
* @author Francesco Trotta
4+
*/
5+
6+
"use strict";
7+
8+
const { flatConfigSchema } = require("../../../lib/config/flat-config-schema");
9+
const { assert } = require("chai");
10+
11+
describe("merge", () => {
12+
13+
const { merge } = flatConfigSchema.settings;
14+
15+
it("merges two objects", () => {
16+
const first = { foo: 42 };
17+
const second = { bar: "baz" };
18+
const result = merge(first, second);
19+
20+
assert.deepStrictEqual(result, { ...first, ...second });
21+
});
22+
23+
it("overrides an object with an array", () => {
24+
const first = { foo: 42 };
25+
const second = ["bar", "baz"];
26+
const result = merge(first, second);
27+
28+
assert.strictEqual(result, second);
29+
});
30+
31+
it("merges an array with an object", () => {
32+
const first = ["foo", "bar"];
33+
const second = { baz: 42 };
34+
const result = merge(first, second);
35+
36+
assert.deepStrictEqual(result, { 0: "foo", 1: "bar", baz: 42 });
37+
});
38+
39+
it("overrides an array with another array", () => {
40+
const first = ["foo", "bar"];
41+
const second = ["baz", "qux"];
42+
const result = merge(first, second);
43+
44+
assert.strictEqual(result, second);
45+
});
46+
47+
it("returns an emtpy object if both values are undefined", () => {
48+
const result = merge(void 0, void 0);
49+
50+
assert.deepStrictEqual(result, {});
51+
});
52+
53+
it("returns an object equal to the first one if the second one is undefined", () => {
54+
const first = { foo: 42, bar: "baz" };
55+
const result = merge(first, void 0);
56+
57+
assert.deepStrictEqual(result, first);
58+
assert.notStrictEqual(result, first);
59+
});
60+
61+
it("returns an object equal to the second one if the first one is undefined", () => {
62+
const second = { foo: 42, bar: "baz" };
63+
const result = merge(void 0, second);
64+
65+
assert.deepStrictEqual(result, second);
66+
assert.notStrictEqual(result, second);
67+
});
68+
69+
it("merges two objects in a property", () => {
70+
const first = { foo: { bar: "baz" } };
71+
const second = { foo: { qux: 42 } };
72+
const result = merge(first, second);
73+
74+
assert.deepStrictEqual(result, { foo: { bar: "baz", qux: 42 } });
75+
});
76+
77+
it("does not override a value in a property with undefined", () => {
78+
const first = { foo: { bar: "baz" } };
79+
const second = { foo: void 0 };
80+
const result = merge(first, second);
81+
82+
assert.deepStrictEqual(result, first);
83+
assert.notStrictEqual(result, first);
84+
});
85+
86+
it("does not change the prototype of a merged object", () => {
87+
const first = { foo: 42 };
88+
const second = { bar: "baz", ["__proto__"]: { qux: true } };
89+
const result = merge(first, second);
90+
91+
assert.strictEqual(Object.getPrototypeOf(result), Object.prototype);
92+
});
93+
94+
it("does not merge the '__proto__' property", () => {
95+
const first = { ["__proto__"]: { foo: 42 } };
96+
const second = { ["__proto__"]: { bar: "baz" } };
97+
const result = merge(first, second);
98+
99+
assert.deepStrictEqual(result, second);
100+
assert.notStrictEqual(result, second);
101+
});
102+
103+
it("throws an error if a value in a property is overriden with null", () => {
104+
const first = { foo: { bar: "baz" } };
105+
const second = { foo: null };
106+
107+
assert.throws(() => merge(first, second), TypeError);
108+
});
109+
110+
it("does not override a value in a property with a primitive", () => {
111+
const first = { foo: { bar: "baz" } };
112+
const second = { foo: 42 };
113+
const result = merge(first, second);
114+
115+
assert.deepStrictEqual(result, first);
116+
assert.notStrictEqual(result, first);
117+
});
118+
119+
it("merges an object in a property with a string", () => {
120+
const first = { foo: { bar: "baz" } };
121+
const second = { foo: "qux" };
122+
const result = merge(first, second);
123+
124+
assert.deepStrictEqual(result, { foo: { 0: "q", 1: "u", 2: "x", bar: "baz" } });
125+
});
126+
127+
it("merges objects with self-references", () => {
128+
const first = { foo: 42 };
129+
130+
first.first = first;
131+
const second = { bar: "baz" };
132+
133+
second.second = second;
134+
const result = merge(first, second);
135+
136+
assert.strictEqual(result.first, first);
137+
assert.deepStrictEqual(result.second, second);
138+
139+
const expected = { foo: 42, bar: "baz" };
140+
141+
expected.first = first;
142+
expected.second = second;
143+
assert.deepStrictEqual(result, expected);
144+
});
145+
146+
it("merges objects with overlapping self-references", () => {
147+
const first = { foo: 42 };
148+
149+
first.reference = first;
150+
const second = { bar: "baz" };
151+
152+
second.reference = second;
153+
154+
const result = merge(first, second);
155+
156+
assert.strictEqual(result.reference, result);
157+
158+
const expected = { foo: 42, bar: "baz" };
159+
160+
expected.reference = expected;
161+
assert.deepStrictEqual(result, expected);
162+
});
163+
164+
it("merges objects with cross-references", () => {
165+
const first = { foo: 42 };
166+
const second = { bar: "baz" };
167+
168+
first.second = second;
169+
second.first = first;
170+
171+
const result = merge(first, second);
172+
173+
assert.deepStrictEqual(result.first, first);
174+
assert.strictEqual(result.second, second);
175+
176+
const expected = { foo: 42, bar: "baz" };
177+
178+
expected.first = first;
179+
expected.second = second;
180+
assert.deepStrictEqual(result, expected);
181+
});
182+
183+
it("merges objects with overlapping cross-references", () => {
184+
const first = { foo: 42 };
185+
const second = { bar: "baz" };
186+
187+
first.reference = second;
188+
second.reference = first;
189+
190+
const result = merge(first, second);
191+
192+
assert.strictEqual(result, result.reference.reference);
193+
194+
const expected = { foo: 42, bar: "baz", reference: { foo: 42, bar: "baz" } };
195+
196+
expected.reference.reference = expected;
197+
assert.deepStrictEqual(result, expected);
198+
});
199+
200+
it("produces the same results for the same combinations of property values", () => {
201+
const firstCommon = { foo: 42 };
202+
const secondCommon = { bar: "baz" };
203+
const first = {
204+
a: firstCommon,
205+
b: firstCommon,
206+
c: { foo: "different" },
207+
d: firstCommon
208+
};
209+
const second = {
210+
a: secondCommon,
211+
b: { bar: "something else" },
212+
c: secondCommon,
213+
d: secondCommon
214+
};
215+
const result = merge(first, second);
216+
217+
assert.deepStrictEqual(result.a, result.d);
218+
219+
const expected = {
220+
a: { foo: 42, bar: "baz" },
221+
b: { foo: 42, bar: "something else" },
222+
c: { foo: "different", bar: "baz" },
223+
d: { foo: 42, bar: "baz" }
224+
};
225+
226+
assert.deepStrictEqual(result, expected);
227+
});
228+
});

tests/lib/eslint/flat-eslint.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6322,6 +6322,72 @@ describe("FlatESLint", () => {
63226322
});
63236323
}
63246324

6325+
describe("config with circular references", () => {
6326+
it("in 'settings'", async () => {
6327+
let resolvedSettings = null;
6328+
6329+
const circular = {};
6330+
6331+
circular.self = circular;
6332+
6333+
const eslint = new FlatESLint({
6334+
overrideConfigFile: true,
6335+
baseConfig: {
6336+
settings: {
6337+
sharedData: circular
6338+
},
6339+
rules: {
6340+
"test-plugin/test-rule": 1
6341+
}
6342+
},
6343+
plugins: {
6344+
"test-plugin": {
6345+
rules: {
6346+
"test-rule": {
6347+
create(context) {
6348+
resolvedSettings = context.settings;
6349+
return { };
6350+
}
6351+
}
6352+
}
6353+
}
6354+
}
6355+
});
6356+
6357+
await eslint.lintText("debugger;");
6358+
6359+
assert.deepStrictEqual(resolvedSettings.sharedData, circular);
6360+
});
6361+
6362+
it("in 'parserOptions'", async () => {
6363+
let resolvedParserOptions = null;
6364+
6365+
const circular = {};
6366+
6367+
circular.self = circular;
6368+
6369+
const eslint = new FlatESLint({
6370+
overrideConfigFile: true,
6371+
baseConfig: {
6372+
languageOptions: {
6373+
parser: {
6374+
parse(text, parserOptions) {
6375+
resolvedParserOptions = parserOptions;
6376+
}
6377+
},
6378+
parserOptions: {
6379+
testOption: circular
6380+
}
6381+
}
6382+
}
6383+
});
6384+
6385+
await eslint.lintText("debugger;");
6386+
6387+
assert.deepStrictEqual(resolvedParserOptions.testOption, circular);
6388+
});
6389+
});
6390+
63256391
});
63266392

63276393
describe("shouldUseFlatConfig", () => {

0 commit comments

Comments
 (0)