feat: add allowlist compatibility check for API proposals in extensions (#289923)

This commit is contained in:
João Moreno
2026-01-23 15:56:23 +01:00
committed by GitHub
parent 59ea193416
commit 69390a65e7
2 changed files with 119 additions and 1 deletions

View File

@@ -7,7 +7,7 @@ import { CosmosClient } from '@azure/cosmos';
import path from 'path';
import fs from 'fs';
import { retry } from './retry.ts';
import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility } from './versionCompatibility.ts';
import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility, areAllowlistedApiProposalsMatching } from './versionCompatibility.ts';
const root = path.dirname(path.dirname(path.dirname(import.meta.dirname)));
@@ -75,6 +75,25 @@ async function checkCopilotChatCompatibility(): Promise<void> {
console.log(`Loaded ${proposalCount} API proposals from source`);
// Load product.json to check allowlisted API proposals
const productJsonPath = path.join(root, 'product.json');
let productJson;
try {
productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8'));
} catch (error) {
throw new Error(`Failed to load or parse product.json: ${error}`);
}
const productAllowlistedProposals = productJson?.extensionEnabledApiProposals?.[extensionId];
if (productAllowlistedProposals) {
console.log(`Product.json allowlisted proposals for ${extensionId}:`);
for (const proposal of productAllowlistedProposals) {
console.log(` ${proposal}`);
}
} else {
console.log(`Product.json allowlisted proposals for ${extensionId}: none`);
}
// Fetch the latest extension manifest
const manifest = await retry(() => fetchLatestExtensionManifest(extensionId));
@@ -92,6 +111,14 @@ async function checkCopilotChatCompatibility(): Promise<void> {
if (manifest.enabledApiProposals?.length) {
console.log(` ✓ API proposals compatible`);
}
// Check that product.json allowlist matches package.json declarations
const allowlistResult = areAllowlistedApiProposalsMatching(extensionId, productAllowlistedProposals, manifest.enabledApiProposals);
if (!allowlistResult.compatible) {
throw new Error(`Allowlist check failed:\n ${allowlistResult.errors.join('\n ')}`);
}
console.log(` ✓ Product.json allowlist matches package.json`);
console.log(`${extensionId} is compatible with this build`);
}

View File

@@ -124,6 +124,50 @@ export function parseApiProposalsFromSource(content: string): { [proposalName: s
return allApiProposals;
}
export function areAllowlistedApiProposalsMatching(
extensionId: string,
productAllowlistedProposals: string[] | undefined,
manifestEnabledProposals: string[] | undefined
): { compatible: boolean; errors: string[] } {
// Normalize undefined to empty arrays for easier comparison
const productProposals = productAllowlistedProposals || [];
const manifestProposals = manifestEnabledProposals || [];
// If extension doesn't declare any proposals, it's always compatible
// (product.json can allowlist more than the extension uses)
if (manifestProposals.length === 0) {
return { compatible: true, errors: [] };
}
// If extension declares API proposals but product.json doesn't allowlist them
if (productProposals.length === 0) {
return {
compatible: false,
errors: [
`Extension '${extensionId}' declares API proposals in package.json (${manifestProposals.join(', ')}) ` +
`but product.json does not allowlist any API proposals for this extension`
]
};
}
// Check that all proposals in manifest are allowlisted in product.json
// (product.json can have extra proposals that the extension doesn't use)
// Note: Strip version suffixes from manifest proposals (e.g., "chatParticipant@2" -> "chatParticipant")
// because product.json only contains base proposal names
const productSet = new Set(productProposals);
const errors: string[] = [];
for (const proposal of manifestProposals) {
// Strip version suffix if present (e.g., "chatParticipant@2" -> "chatParticipant")
const proposalName = proposal.split('@')[0];
if (!productSet.has(proposalName)) {
errors.push(`API proposal '${proposal}' is declared in extension '${extensionId}' package.json but is not allowlisted in product.json`);
}
}
return { compatible: errors.length === 0, errors };
}
export function checkExtensionCompatibility(
productVersion: string,
productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>,
@@ -343,5 +387,52 @@ export const allApiProposals = {
console.log(' ✓ checkExtensionCompatibility tests passed\n');
// areAllowlistedApiProposalsMatching tests
console.log('Testing areAllowlistedApiProposalsMatching...');
// Both undefined - compatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, undefined).compatible, true);
// Both empty arrays - compatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], []).compatible, true);
// Exact match - compatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB']).compatible, true);
// Match regardless of order - compatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalB', 'proposalA'], ['proposalA', 'proposalB']).compatible, true);
// Extension declares but product.json doesn't allowlist - incompatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, ['proposalA']).compatible, false);
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], ['proposalA']).compatible, false);
// Product.json allowlists but extension doesn't declare - COMPATIBLE (product.json can have extras)
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], undefined).compatible, true);
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], []).compatible, true);
// Extension declares more than allowlisted - incompatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalA', 'proposalB']).compatible, false);
// Product.json allowlists more than declared - COMPATIBLE (product.json can have extras)
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA']).compatible, true);
// Completely different sets - incompatible (manifest has proposals not in allowlist)
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB']).compatible, false);
// Product.json has extras and manifest matches subset - compatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB', 'proposalC'], ['proposalA', 'proposalB']).compatible, true);
// Versioned proposals - should strip version and match base name
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['chatParticipant'], ['chatParticipant@2']).compatible, true);
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA@1', 'proposalB@3']).compatible, true);
// Versioned proposal not in allowlist - incompatible
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB@2']).compatible, false);
// Mix of versioned and unversioned proposals
assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB@2']).compatible, true);
console.log(' ✓ areAllowlistedApiProposalsMatching tests passed\n');
console.log('All tests passed! ✓');
}