import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; const repoRoot = process.cwd(); const apiFeaturesRoot = path.join(repoRoot, 'apps', 'api', 'src', 'features'); const taroSrcRoot = path.join(repoRoot, 'apps', 'taro', 'src'); const dynamicApiAllowlist = [ { method: 'POST', frontendPattern: /^\/api\/tenant-content\/imports\/preview\/[^/]+$/, backendPaths: [ '/api/tenant-content/imports/preview/questions', '/api/tenant-content/imports/preview/vocabulary', '/api/tenant-content/imports/preview/handbook', '/api/tenant-content/imports/preview/scoreline', '/api/tenant-content/imports/preview/videos', ], }, { method: 'POST', frontendPattern: /^\/api\/tenant-content\/imports\/[^/]+$/, backendPaths: [ '/api/tenant-content/imports/questions', '/api/tenant-content/imports/vocabulary', '/api/tenant-content/imports/handbook', '/api/tenant-content/imports/scoreline', '/api/tenant-content/imports/videos', ], }, ]; const serverAliases = [ { method: 'GET', frontendPattern: /^\/api\/questions\/[^/]+\/videos$/, backendPaths: ['/api/questions/videos'], }, ]; function readText(filePath) { return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } function normalizeSlashes(value) { return value.replace(/\\/g, '/'); } function relative(filePath) { return normalizeSlashes(path.relative(repoRoot, filePath)); } function walkFiles(dir, extensions) { const result = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { result.push(...walkFiles(entryPath, extensions)); continue; } if (extensions.some(extension => entry.name.endsWith(extension))) result.push(entryPath); } return result; } function extractBackendRoutes() { const files = walkFiles(apiFeaturesRoot, ['.ts']); const routes = []; const routeRegex = /\[\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]\s*,\s*['"`](\/[^'"`]+)['"`]\s*,/g; for (const filePath of files) { const text = readText(filePath); for (const match of text.matchAll(routeRegex)) { routes.push({ method: match[1], path: match[2], file: relative(filePath), }); } } return routes; } function skipString(text, index) { const quote = text[index]; let cursor = index + 1; while (cursor < text.length) { const char = text[cursor]; if (char === '\\') { cursor += 2; continue; } if (char === quote) return cursor + 1; cursor += 1; } return cursor; } function skipTemplate(text, index) { let cursor = index + 1; while (cursor < text.length) { const char = text[cursor]; if (char === '\\') { cursor += 2; continue; } if (char === '`') return cursor + 1; if (char === '$' && text[cursor + 1] === '{') { cursor = findMatching(text, cursor + 1, '{', '}') + 1; continue; } cursor += 1; } return cursor; } function findMatching(text, openIndex, openChar, closeChar) { let depth = 0; for (let cursor = openIndex; cursor < text.length; cursor += 1) { const char = text[cursor]; if (char === '"' || char === "'") { cursor = skipString(text, cursor) - 1; continue; } if (char === '`') { cursor = skipTemplate(text, cursor) - 1; continue; } if (char === openChar) depth += 1; if (char === closeChar) { depth -= 1; if (depth === 0) return cursor; } } return text.length - 1; } function splitTopLevelArguments(text) { const args = []; let start = 0; let round = 0; let curly = 0; let square = 0; for (let cursor = 0; cursor < text.length; cursor += 1) { const char = text[cursor]; if (char === '"' || char === "'") { cursor = skipString(text, cursor) - 1; continue; } if (char === '`') { cursor = skipTemplate(text, cursor) - 1; continue; } if (char === '(') round += 1; else if (char === ')') round -= 1; else if (char === '{') curly += 1; else if (char === '}') curly -= 1; else if (char === '[') square += 1; else if (char === ']') square -= 1; else if (char === ',' && round === 0 && curly === 0 && square === 0) { args.push(text.slice(start, cursor).trim()); start = cursor + 1; } } args.push(text.slice(start).trim()); return args; } function parseFirstStringArgument(argument) { const trimmed = argument.trim(); const quote = trimmed[0]; if (!['"', "'", '`'].includes(quote)) return null; let value = ''; for (let index = 1; index < trimmed.length; index += 1) { const char = trimmed[index]; if (char === '\\') { value += trimmed.slice(index, index + 2); index += 1; continue; } if (quote === '`' && char === '$' && trimmed[index + 1] === '{') { const end = findMatching(trimmed, index + 1, '{', '}'); value += '${dynamic}'; index = end; continue; } if (char === quote) { return value; } value += char; } return null; } function extractApiCallsFromFile(filePath) { const text = readText(filePath); const calls = []; const callPattern = /\bapiRequest\b/g; for (const match of text.matchAll(callPattern)) { const openIndex = findCallOpen(text, match.index + 'apiRequest'.length); if (openIndex < 0) continue; const closeIndex = findMatching(text, openIndex, '(', ')'); const source = text.slice(openIndex + 1, closeIndex); const args = splitTopLevelArguments(source); const rawPath = parseFirstStringArgument(args[0] || ''); if (!rawPath) continue; const methodMatch = (args[1] || '').match(/\bmethod\s*:\s*['"`](GET|POST|PUT|PATCH|DELETE)['"`]/); const line = text.slice(0, match.index).split('\n').length; calls.push({ method: methodMatch?.[1] || 'GET', path: rawPath, file: relative(filePath), line, }); } return calls; } function findCallOpen(text, startIndex) { let angle = 0; for (let cursor = startIndex; cursor < text.length; cursor += 1) { const char = text[cursor]; if (char === '"' || char === "'") { cursor = skipString(text, cursor) - 1; continue; } if (char === '`') { cursor = skipTemplate(text, cursor) - 1; continue; } if (char === '<') { angle += 1; continue; } if (char === '>' && angle > 0) { angle -= 1; continue; } if (char === '(' && angle === 0) return cursor; if (angle === 0 && !/\s/.test(char)) return -1; } return -1; } function extractFrontendApiCalls() { return walkFiles(taroSrcRoot, ['.ts', '.tsx']).flatMap(extractApiCallsFromFile); } function dynamicPathToRegex(pathPattern) { const escaped = pathPattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .replace(/\\\$\\\{dynamic\\\}/g, '[^/]+'); return new RegExp(`^${escaped}$`); } function formatCall(call) { return `${call.method} ${call.path} (${call.file}:${call.line})`; } const backendRoutes = extractBackendRoutes(); const routeKeys = new Set(backendRoutes.map(route => `${route.method} ${route.path}`)); const duplicateRoutes = backendRoutes .map(route => `${route.method} ${route.path}`) .filter((key, index, items) => items.indexOf(key) !== index); assert.deepEqual([...new Set(duplicateRoutes)].sort(), [], 'Backend route registry must not contain duplicate method/path pairs'); const frontendCalls = extractFrontendApiCalls(); const invalidPaths = frontendCalls.filter(call => !call.path.startsWith('/api/')); assert.deepEqual( invalidPaths.map(formatCall), [], 'Taro apiRequest calls must target /api/... paths. Use env.ts only for runtime-config.json.', ); function isAllowedDynamic(call) { const rules = [...dynamicApiAllowlist, ...serverAliases].filter(rule => rule.method === call.method); return rules.some(rule => { if (!rule.frontendPattern.test(call.path) && !dynamicPathToRegex(call.path).test(rule.backendPaths[0] || '')) return false; return rule.backendPaths.every(backendPath => routeKeys.has(`${rule.method} ${backendPath}`)); }); } const missingCalls = frontendCalls.filter(call => { if (routeKeys.has(`${call.method} ${call.path}`)) return false; if (isAllowedDynamic(call)) return false; return true; }); assert.deepEqual( missingCalls.map(formatCall), [], 'Every Taro apiRequest method/path must be registered by apps/api or explicitly covered by a server alias.', ); const coverage = { backendRoutes: backendRoutes.length, frontendCalls: frontendCalls.length, uniqueFrontendCalls: new Set(frontendCalls.map(call => `${call.method} ${call.path}`)).size, }; console.log( `[PASS] Taro API contract (${coverage.frontendCalls} calls, ${coverage.uniqueFrontendCalls} unique frontend method/path pairs, ${coverage.backendRoutes} backend routes)`, );