Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/build/azure-pipelines/common/versionCompatibility.ts
5241 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
8
export interface IExtensionManifest {
9
name: string;
10
publisher: string;
11
version: string;
12
engines: { vscode: string };
13
main?: string;
14
browser?: string;
15
enabledApiProposals?: string[];
16
}
17
18
export function isEngineCompatible(productVersion: string, engineVersion: string): { compatible: boolean; error?: string } {
19
if (engineVersion === '*') {
20
return { compatible: true };
21
}
22
23
const versionMatch = engineVersion.match(/^(\^|>=)?(\d+)\.(\d+)\.(\d+)/);
24
if (!versionMatch) {
25
return { compatible: false, error: `Could not parse engines.vscode value: ${engineVersion}` };
26
}
27
28
const [, prefix, major, minor, patch] = versionMatch;
29
const productMatch = productVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
30
if (!productMatch) {
31
return { compatible: false, error: `Could not parse product version: ${productVersion}` };
32
}
33
34
const [, prodMajor, prodMinor, prodPatch] = productMatch;
35
36
const reqMajor = parseInt(major);
37
const reqMinor = parseInt(minor);
38
const reqPatch = parseInt(patch);
39
const pMajor = parseInt(prodMajor);
40
const pMinor = parseInt(prodMinor);
41
const pPatch = parseInt(prodPatch);
42
43
if (prefix === '>=') {
44
// Minimum version check
45
if (pMajor > reqMajor) { return { compatible: true }; }
46
if (pMajor < reqMajor) { return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; }
47
if (pMinor > reqMinor) { return { compatible: true }; }
48
if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; }
49
if (pPatch >= reqPatch) { return { compatible: true }; }
50
return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` };
51
}
52
53
// Caret or exact version check
54
if (pMajor !== reqMajor) {
55
return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion} (major version mismatch)` };
56
}
57
58
if (prefix === '^') {
59
// Caret: same major, minor and patch must be >= required
60
if (pMinor > reqMinor) { return { compatible: true }; }
61
if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; }
62
if (pPatch >= reqPatch) { return { compatible: true }; }
63
return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` };
64
}
65
66
// Exact or default behavior
67
if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; }
68
if (pMinor > reqMinor) { return { compatible: true }; }
69
if (pPatch >= reqPatch) { return { compatible: true }; }
70
return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` };
71
}
72
73
export function parseApiProposals(enabledApiProposals: string[]): { proposalName: string; version?: number }[] {
74
return enabledApiProposals.map(proposal => {
75
const [proposalName, version] = proposal.split('@');
76
return { proposalName, version: version ? parseInt(version) : undefined };
77
});
78
}
79
80
export function areApiProposalsCompatible(
81
apiProposals: string[],
82
productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>
83
): { compatible: boolean; errors: string[] } {
84
if (apiProposals.length === 0) {
85
return { compatible: true, errors: [] };
86
}
87
88
const errors: string[] = [];
89
const parsedProposals = parseApiProposals(apiProposals);
90
91
for (const { proposalName, version } of parsedProposals) {
92
if (!version) {
93
continue;
94
}
95
const existingProposal = productApiProposals[proposalName];
96
if (!existingProposal) {
97
errors.push(`API proposal '${proposalName}' does not exist in this version of VS Code`);
98
} else if (existingProposal.version !== version) {
99
errors.push(`API proposal '${proposalName}' version mismatch: extension requires version ${version}, but VS Code has version ${existingProposal.version ?? 'unversioned'}`);
100
}
101
}
102
103
return { compatible: errors.length === 0, errors };
104
}
105
106
export function parseApiProposalsFromSource(content: string): { [proposalName: string]: { proposal: string; version?: number } } {
107
const allApiProposals: { [proposalName: string]: { proposal: string; version?: number } } = {};
108
109
// Match proposal blocks like: proposalName: {\n\t\tproposal: '...',\n\t\tversion: N\n\t}
110
// or: proposalName: {\n\t\tproposal: '...',\n\t}
111
const proposalBlockRegex = /\t(\w+):\s*\{([^}]+)\}/g;
112
const versionRegex = /version:\s*(\d+)/;
113
114
let match;
115
while ((match = proposalBlockRegex.exec(content)) !== null) {
116
const [, name, block] = match;
117
const versionMatch = versionRegex.exec(block);
118
allApiProposals[name] = {
119
proposal: '',
120
version: versionMatch ? parseInt(versionMatch[1]) : undefined
121
};
122
}
123
124
return allApiProposals;
125
}
126
127
export function areAllowlistedApiProposalsMatching(
128
extensionId: string,
129
productAllowlistedProposals: string[] | undefined,
130
manifestEnabledProposals: string[] | undefined
131
): { compatible: boolean; errors: string[] } {
132
// Normalize undefined to empty arrays for easier comparison
133
const productProposals = productAllowlistedProposals || [];
134
const manifestProposals = manifestEnabledProposals || [];
135
136
// If extension doesn't declare any proposals, it's always compatible
137
// (product.json can allowlist more than the extension uses)
138
if (manifestProposals.length === 0) {
139
return { compatible: true, errors: [] };
140
}
141
142
// If extension declares API proposals but product.json doesn't allowlist them
143
if (productProposals.length === 0) {
144
return {
145
compatible: false,
146
errors: [
147
`Extension '${extensionId}' declares API proposals in package.json (${manifestProposals.join(', ')}) ` +
148
`but product.json does not allowlist any API proposals for this extension`
149
]
150
};
151
}
152
153
// Check that all proposals in manifest are allowlisted in product.json
154
// (product.json can have extra proposals that the extension doesn't use)
155
// Note: Strip version suffixes from manifest proposals (e.g., "chatParticipant@2" -> "chatParticipant")
156
// because product.json only contains base proposal names
157
const productSet = new Set(productProposals);
158
const errors: string[] = [];
159
160
for (const proposal of manifestProposals) {
161
// Strip version suffix if present (e.g., "chatParticipant@2" -> "chatParticipant")
162
const proposalName = proposal.split('@')[0];
163
if (!productSet.has(proposalName)) {
164
errors.push(`API proposal '${proposal}' is declared in extension '${extensionId}' package.json but is not allowlisted in product.json`);
165
}
166
}
167
168
return { compatible: errors.length === 0, errors };
169
}
170
171
export function checkExtensionCompatibility(
172
productVersion: string,
173
productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>,
174
manifest: IExtensionManifest
175
): { compatible: boolean; errors: string[] } {
176
const errors: string[] = [];
177
178
// Check engine compatibility
179
const engineResult = isEngineCompatible(productVersion, manifest.engines.vscode);
180
if (!engineResult.compatible) {
181
errors.push(engineResult.error!);
182
}
183
184
// Check API proposals compatibility
185
if (manifest.enabledApiProposals?.length) {
186
const apiResult = areApiProposalsCompatible(manifest.enabledApiProposals, productApiProposals);
187
if (!apiResult.compatible) {
188
errors.push(...apiResult.errors);
189
}
190
}
191
192
return { compatible: errors.length === 0, errors };
193
}
194
195
if (import.meta.main) {
196
console.log('Running version compatibility tests...\n');
197
198
// isEngineCompatible tests
199
console.log('Testing isEngineCompatible...');
200
201
// Wildcard
202
assert.strictEqual(isEngineCompatible('1.50.0', '*').compatible, true);
203
204
// Invalid engine version
205
assert.strictEqual(isEngineCompatible('1.50.0', 'invalid').compatible, false);
206
207
// Invalid product version
208
assert.strictEqual(isEngineCompatible('invalid', '1.50.0').compatible, false);
209
210
// >= prefix
211
assert.strictEqual(isEngineCompatible('1.50.0', '>=1.50.0').compatible, true);
212
assert.strictEqual(isEngineCompatible('1.50.1', '>=1.50.0').compatible, true);
213
assert.strictEqual(isEngineCompatible('1.51.0', '>=1.50.0').compatible, true);
214
assert.strictEqual(isEngineCompatible('2.0.0', '>=1.50.0').compatible, true);
215
assert.strictEqual(isEngineCompatible('1.49.0', '>=1.50.0').compatible, false);
216
assert.strictEqual(isEngineCompatible('1.50.0', '>=1.50.1').compatible, false);
217
assert.strictEqual(isEngineCompatible('0.50.0', '>=1.50.0').compatible, false);
218
219
// ^ prefix (caret)
220
assert.strictEqual(isEngineCompatible('1.50.0', '^1.50.0').compatible, true);
221
assert.strictEqual(isEngineCompatible('1.50.1', '^1.50.0').compatible, true);
222
assert.strictEqual(isEngineCompatible('1.51.0', '^1.50.0').compatible, true);
223
assert.strictEqual(isEngineCompatible('1.49.0', '^1.50.0').compatible, false);
224
assert.strictEqual(isEngineCompatible('1.50.0', '^1.50.1').compatible, false);
225
assert.strictEqual(isEngineCompatible('2.0.0', '^1.50.0').compatible, false);
226
227
// Exact/default (no prefix)
228
assert.strictEqual(isEngineCompatible('1.50.0', '1.50.0').compatible, true);
229
assert.strictEqual(isEngineCompatible('1.50.1', '1.50.0').compatible, true);
230
assert.strictEqual(isEngineCompatible('1.51.0', '1.50.0').compatible, true);
231
assert.strictEqual(isEngineCompatible('1.49.0', '1.50.0').compatible, false);
232
assert.strictEqual(isEngineCompatible('1.50.0', '1.50.1').compatible, false);
233
assert.strictEqual(isEngineCompatible('2.0.0', '1.50.0').compatible, false);
234
235
console.log(' ✓ isEngineCompatible tests passed\n');
236
237
// parseApiProposals tests
238
console.log('Testing parseApiProposals...');
239
240
assert.deepStrictEqual(parseApiProposals([]), []);
241
assert.deepStrictEqual(parseApiProposals(['proposalA']), [{ proposalName: 'proposalA', version: undefined }]);
242
assert.deepStrictEqual(parseApiProposals(['proposalA@1']), [{ proposalName: 'proposalA', version: 1 }]);
243
assert.deepStrictEqual(parseApiProposals(['proposalA@1', 'proposalB', 'proposalC@3']), [
244
{ proposalName: 'proposalA', version: 1 },
245
{ proposalName: 'proposalB', version: undefined },
246
{ proposalName: 'proposalC', version: 3 }
247
]);
248
249
console.log(' ✓ parseApiProposals tests passed\n');
250
251
// areApiProposalsCompatible tests
252
console.log('Testing areApiProposalsCompatible...');
253
254
const productProposals = {
255
proposalA: { proposal: '', version: 1 },
256
proposalB: { proposal: '', version: 2 },
257
proposalC: { proposal: '' } // unversioned
258
};
259
260
// Empty proposals
261
assert.strictEqual(areApiProposalsCompatible([], productProposals).compatible, true);
262
263
// Unversioned extension proposals (always compatible)
264
assert.strictEqual(areApiProposalsCompatible(['proposalA', 'proposalB'], productProposals).compatible, true);
265
assert.strictEqual(areApiProposalsCompatible(['unknownProposal'], productProposals).compatible, true);
266
267
// Versioned proposals - matching
268
assert.strictEqual(areApiProposalsCompatible(['proposalA@1'], productProposals).compatible, true);
269
assert.strictEqual(areApiProposalsCompatible(['proposalA@1', 'proposalB@2'], productProposals).compatible, true);
270
271
// Versioned proposals - version mismatch
272
assert.strictEqual(areApiProposalsCompatible(['proposalA@2'], productProposals).compatible, false);
273
assert.strictEqual(areApiProposalsCompatible(['proposalB@1'], productProposals).compatible, false);
274
275
// Versioned proposals - missing proposal
276
assert.strictEqual(areApiProposalsCompatible(['unknownProposal@1'], productProposals).compatible, false);
277
278
// Versioned proposals - product has unversioned
279
assert.strictEqual(areApiProposalsCompatible(['proposalC@1'], productProposals).compatible, false);
280
281
// Mixed versioned and unversioned
282
assert.strictEqual(areApiProposalsCompatible(['proposalA@1', 'proposalB'], productProposals).compatible, true);
283
assert.strictEqual(areApiProposalsCompatible(['proposalA@2', 'proposalB'], productProposals).compatible, false);
284
285
console.log(' ✓ areApiProposalsCompatible tests passed\n');
286
287
// parseApiProposalsFromSource tests
288
console.log('Testing parseApiProposalsFromSource...');
289
290
const sampleSource = `
291
export const allApiProposals = {
292
authSession: {
293
proposal: 'vscode.proposed.authSession.d.ts',
294
},
295
chatParticipant: {
296
proposal: 'vscode.proposed.chatParticipant.d.ts',
297
version: 2
298
},
299
testProposal: {
300
proposal: 'vscode.proposed.testProposal.d.ts',
301
version: 15
302
}
303
};
304
`;
305
306
const parsedSource = parseApiProposalsFromSource(sampleSource);
307
assert.strictEqual(Object.keys(parsedSource).length, 3);
308
assert.strictEqual(parsedSource['authSession']?.version, undefined);
309
assert.strictEqual(parsedSource['chatParticipant']?.version, 2);
310
assert.strictEqual(parsedSource['testProposal']?.version, 15);
311
312
// Empty source
313
assert.strictEqual(Object.keys(parseApiProposalsFromSource('')).length, 0);
314
315
console.log(' ✓ parseApiProposalsFromSource tests passed\n');
316
317
// checkExtensionCompatibility tests
318
console.log('Testing checkExtensionCompatibility...');
319
320
const testApiProposals = {
321
authSession: { proposal: '', version: undefined },
322
chatParticipant: { proposal: '', version: 2 },
323
testProposal: { proposal: '', version: 15 }
324
};
325
326
// Compatible extension - matching engine and proposals
327
assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {
328
name: 'test-ext',
329
publisher: 'test',
330
version: '1.0.0',
331
engines: { vscode: '^1.90.0' },
332
enabledApiProposals: ['chatParticipant@2']
333
}).compatible, true);
334
335
// Compatible - no API proposals
336
assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {
337
name: 'test-ext',
338
publisher: 'test',
339
version: '1.0.0',
340
engines: { vscode: '^1.90.0' }
341
}).compatible, true);
342
343
// Compatible - unversioned API proposals
344
assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {
345
name: 'test-ext',
346
publisher: 'test',
347
version: '1.0.0',
348
engines: { vscode: '^1.90.0' },
349
enabledApiProposals: ['authSession', 'chatParticipant']
350
}).compatible, true);
351
352
// Incompatible - engine version too new
353
assert.strictEqual(checkExtensionCompatibility('1.89.0', testApiProposals, {
354
name: 'test-ext',
355
publisher: 'test',
356
version: '1.0.0',
357
engines: { vscode: '^1.90.0' },
358
enabledApiProposals: ['chatParticipant@2']
359
}).compatible, false);
360
361
// Incompatible - API proposal version mismatch
362
assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {
363
name: 'test-ext',
364
publisher: 'test',
365
version: '1.0.0',
366
engines: { vscode: '^1.90.0' },
367
enabledApiProposals: ['chatParticipant@3']
368
}).compatible, false);
369
370
// Incompatible - missing API proposal
371
assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, {
372
name: 'test-ext',
373
publisher: 'test',
374
version: '1.0.0',
375
engines: { vscode: '^1.90.0' },
376
enabledApiProposals: ['unknownProposal@1']
377
}).compatible, false);
378
379
// Incompatible - both engine and API proposal issues
380
assert.strictEqual(checkExtensionCompatibility('1.89.0', testApiProposals, {
381
name: 'test-ext',
382
publisher: 'test',
383
version: '1.0.0',
384
engines: { vscode: '^1.90.0' },
385
enabledApiProposals: ['chatParticipant@3']
386
}).compatible, false);
387
388
console.log(' ✓ checkExtensionCompatibility tests passed\n');
389
390
// areAllowlistedApiProposalsMatching tests
391
console.log('Testing areAllowlistedApiProposalsMatching...');
392
393
// Both undefined - compatible
394
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, undefined).compatible, true);
395
396
// Both empty arrays - compatible
397
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], []).compatible, true);
398
399
// Exact match - compatible
400
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB']).compatible, true);
401
402
// Match regardless of order - compatible
403
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalB', 'proposalA'], ['proposalA', 'proposalB']).compatible, true);
404
405
// Extension declares but product.json doesn't allowlist - incompatible
406
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, ['proposalA']).compatible, false);
407
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], ['proposalA']).compatible, false);
408
409
// Product.json allowlists but extension doesn't declare - COMPATIBLE (product.json can have extras)
410
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], undefined).compatible, true);
411
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], []).compatible, true);
412
413
// Extension declares more than allowlisted - incompatible
414
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalA', 'proposalB']).compatible, false);
415
416
// Product.json allowlists more than declared - COMPATIBLE (product.json can have extras)
417
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA']).compatible, true);
418
419
// Completely different sets - incompatible (manifest has proposals not in allowlist)
420
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB']).compatible, false);
421
422
// Product.json has extras and manifest matches subset - compatible
423
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB', 'proposalC'], ['proposalA', 'proposalB']).compatible, true);
424
425
// Versioned proposals - should strip version and match base name
426
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['chatParticipant'], ['chatParticipant@2']).compatible, true);
427
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA@1', 'proposalB@3']).compatible, true);
428
429
// Versioned proposal not in allowlist - incompatible
430
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB@2']).compatible, false);
431
432
// Mix of versioned and unversioned proposals
433
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB@2']).compatible, true);
434
435
console.log(' ✓ areAllowlistedApiProposalsMatching tests passed\n');
436
437
console.log('All tests passed! ✓');
438
}
439
440