import fs from 'node:fs/promises'; import path from 'node:path'; export const SCENARIO_GROUP_COUNTS = Object.freeze({ REL: 11, AUTH: 8, CHAT: 15, MEM: 16, FILE: 8, IMGPG: 8, XLS: 12, PAGE: 13, DATA: 13, SEARCH: 8, AGENT: 11, SCHED: 5, WX: 13, BILL: 7, MS: 9, PLAZA: 5, CFG: 8, UI: 8, COMP: 9, }); export const SCENARIO_MODE_BY_GROUP = Object.freeze({ REL: 'upgrade', AUTH: 'scenarios', CHAT: 'scenarios', MEM: 'scenarios', FILE: 'scenarios', IMGPG: 'scenarios', XLS: 'scenarios', PAGE: 'scenarios', DATA: 'scenarios', SEARCH: 'providers', AGENT: 'scenarios', SCHED: 'scenarios', WX: 'scenarios', BILL: 'scenarios', MS: 'scenarios', PLAZA: 'scenarios', CFG: 'deterministic', UI: 'browser', COMP: 'upgrade', }); const ALWAYS_REQUIRED = new Set([ ...range('REL', 1, 11), 'AUTH-01', 'AUTH-04', 'AUTH-05', 'AUTH-06', 'CHAT-01', 'CHAT-02', 'CHAT-06', 'CHAT-07', 'CHAT-08', 'CHAT-09', 'CHAT-11', 'CHAT-12', 'CHAT-13', 'CHAT-15', 'MEM-08', 'MEM-13', 'MEM-14', 'MEM-15', 'MEM-16', 'FILE-07', 'PAGE-08', 'PAGE-09', 'PAGE-10', 'PAGE-12', 'DATA-06', 'DATA-10', 'DATA-12', 'AGENT-10', 'AGENT-11', ...range('WX', 9, 13), 'BILL-02', 'BILL-03', 'BILL-04', 'MS-01', 'MS-03', 'MS-04', 'CFG-08', ...range('COMP', 1, 4), 'COMP-08', 'COMP-09', ]); function range(prefix, start, end) { return Array.from( { length: end - start + 1 }, (_, index) => `${prefix}-${String(start + index).padStart(2, '0')}`, ); } export function expectedScenarioIds() { return Object.entries(SCENARIO_GROUP_COUNTS).flatMap( ([prefix, count]) => range(prefix, 1, count), ); } export function isScenarioExemptable(id) { return !ALWAYS_REQUIRED.has(id); } export function validateScenarioCatalog(catalog) { const expected = expectedScenarioIds(); const expectedSet = new Set(expected); const ids = catalog.map((scenario) => scenario.id); const unique = new Set(ids); const missing = expected.filter((id) => !unique.has(id)); const unexpected = ids.filter((id) => !expectedSet.has(id)); const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); if (catalog.length !== expected.length || unique.size !== expected.length) { throw new Error( `Release gate catalog must contain exactly ${expected.length} unique scenarios ` + `(found ${catalog.length}/${unique.size})`, ); } if (missing.length || unexpected.length || duplicates.length) { throw new Error( `Release gate catalog mismatch: missing=${missing.join(',') || '-'} ` + `unexpected=${unexpected.join(',') || '-'} duplicates=${[...new Set(duplicates)].join(',') || '-'}`, ); } for (const scenario of catalog) { if (!scenario.name || !scenario.group || !scenario.mode) { throw new Error(`Scenario ${scenario.id} is missing name, group, or mode`); } } return catalog; } export async function loadScenarioCatalog({ root = path.resolve(new URL('..', import.meta.url).pathname), guardianPath = path.join(root, 'docs', 'production-release-guardian.md'), } = {}) { const source = await fs.readFile(guardianPath, 'utf8'); const catalog = []; const scenarioPattern = /^- \*\*([A-Z]+-\d{2})\*\*:(.+)$/gm; for (const match of source.matchAll(scenarioPattern)) { const id = match[1]; const group = id.split('-')[0]; catalog.push({ id, name: match[2].trim(), group, mode: SCENARIO_MODE_BY_GROUP[group], exemptable: isScenarioExemptable(id), }); } return validateScenarioCatalog(catalog); }