test: guard taro api contract

This commit is contained in:
Codex
2026-07-01 01:40:41 +08:00
parent 7559a33459
commit dffc319d66
7 changed files with 327 additions and 5 deletions

View File

@@ -142,10 +142,12 @@ apps/taro/dist/h5-platform-admin
```bash ```bash
node scripts/taro-route-contract-test.js node scripts/taro-route-contract-test.js
node scripts/taro-api-contract-test.js
npm run smoke:taro:h5 npm run smoke:taro:h5
``` ```
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。 `taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包: H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
@@ -594,6 +596,8 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
| 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms | | 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms |
| 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms | | 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms |
| 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms | | 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms |
| 100 | 60s | 8% | 43,379 | 0.00% | 710.38 req/s | 257.59 ms | 328.75 ms |
| 150 | 60s | 6% | 43,738 | 0.00% | 716.06 req/s | 375.03 ms | 485.60 ms |
| 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms | | 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms |
| 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms | | 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms |
| 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms | | 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms |
@@ -607,7 +611,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。 使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
本地结论100 个无停顿 worker 内 P95 仍低于 300ms150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s最新混合读写 100 worker/60s/8% 写入为 838.42 req/s、P95 210.07ms、0 错误。粗略按单学生 0.05 到 0.2 req/s 可换算为数千到一万多名活跃在线学生请求吞吐,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on``statement_timeout``idle_in_transaction_session_timeout``lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见: 本地结论100 个无停顿 worker 内 P95 仍低于 300ms150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s最新混合读写 100 worker/60s/8% 写入为 710.38 req/s、P95 257.59ms、0 错误150 worker/60s/6% 写入仍 0 错误,但 P95 已到 375.03ms。工程上建议把本机 Docker 舒适区暂记为 100 个无停顿压测 worker按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,约可支撑 3,000 到 14,000 名活跃在线学生请求吞吐观察区间,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on``statement_timeout``idle_in_transaction_session_timeout``lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
```text ```text
docs/refactor/performance-benchmark-summary-20260630.md docs/refactor/performance-benchmark-summary-20260630.md

View File

@@ -54,6 +54,7 @@
- 严禁为学生头像新增上传链路、对象存储签名、第三方头像落库或后台批量导入字段;学生头像只允许 `avatarPreset=male/female` 默认资源,租户后台学生接口也不能写头像 URL 或平台主角色字段。 - 严禁为学生头像新增上传链路、对象存储签名、第三方头像落库或后台批量导入字段;学生头像只允许 `avatarPreset=male/female` 默认资源,租户后台学生接口也不能写头像 URL 或平台主角色字段。
- 严禁在学生端默认请求或展示排行榜;排行榜接口只作为租户显式开启后的活动能力,默认学习激励以后台配置的勋章自动发放为主。 - 严禁在学生端默认请求或展示排行榜;排行榜接口只作为租户显式开启后的活动能力,默认学习激励以后台配置的勋章自动发放为主。
- 严禁新增 Taro 页面后不注册路由、不更新启动页/静态烟测入口或不更新前端交接清单;页面变更后必须运行 `node scripts/taro-route-contract-test.js`,确保 `app.config.ts``pages/**/index.tsx`、启动页跳转和交接文档一致。 - 严禁新增 Taro 页面后不注册路由、不更新启动页/静态烟测入口或不更新前端交接清单;页面变更后必须运行 `node scripts/taro-route-contract-test.js`,确保 `app.config.ts``pages/**/index.tsx`、启动页跳转和交接文档一致。
- 严禁在 Taro service 中调用未注册的后端 API、写错 HTTP method 或绕过统一 `/api` 命名空间API service 变更后必须运行 `node scripts/taro-api-contract-test.js`,动态路由必须在脚本 allowlist 中显式说明后端落点。
## Supabase 直连表的准入条件 ## Supabase 直连表的准入条件

View File

@@ -42,6 +42,7 @@
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 - H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
- `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization``x-tenant-id``x-user-id` - `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization``x-tenant-id``x-user-id`
- 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。 - 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。
- 新增或修改 Taro API service 时必须运行 `node scripts/taro-api-contract-test.js`。该脚本会比对前端 `apiRequest('/api/...')` 与后端 `RouteDefinition[]` 注册表阻断调用不存在的接口、method 写错或绕过统一 API client动态路由只能通过脚本 allowlist 明确声明。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api` - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 - H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config``runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。 - 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config``runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。

View File

@@ -94,7 +94,7 @@
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。 - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
- 确认数据库迁移流程、备份恢复、日志、告警。 - 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类角色旅程烟测H5 发布目录启动烟测。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;混合读写复测 50 worker/60s/10% 写入为 51425 请求、0 错误、845.06 req/s、P95 110.39ms、P99 138.79ms100 worker/60s/8% 写入为 51103 请求、0 错误、838.42 req/s、P95 210.07ms、P99 272.15ms150 worker/60s/6% 写入为 44889 请求、0 错误、735.22 req/s、P95 357.74ms、P99 469.28ms说明本地压力上沿仍在 100 到 150 个无停顿 worker 之间。 - 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``node scripts/taro-api-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类角色旅程烟测H5 发布目录启动烟测和前端 API 契约检查。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms最新混合读写复测 100 worker/60s/8% 写入为 43379 请求、0 错误、710.38 req/s、P95 257.59ms、P99 328.75ms150 worker/60s/6% 写入为 43738 请求、0 错误、716.06 req/s、P95 375.03ms、P99 485.60ms说明本地舒适区暂按 100 个无停顿 worker 估算,压力上沿仍在 100 到 150 worker 之间。
- 当前本地 PostgreSQL evidence 仍提示 `jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。 - 当前本地 PostgreSQL evidence 仍提示 `jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
### P1 商用功能完善 ### P1 商用功能完善

View File

@@ -58,10 +58,21 @@
同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning`jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。 同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning`jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。
### 2026-07-01 API 契约门禁后读写复核
本轮在新增 `node scripts/taro-api-contract-test.js` 并纳入 `npm run test:readiness` 后复跑,确认前端接口契约守卫不会影响后端真实数据刷题读写容量。压测仍使用本地 Docker/Supabase 真实迁移库,排行榜未纳入默认负载。
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 100 | 60s | 8% | 43,379 | 0.00% | 710.38 req/s | 257.59 ms | 328.75 ms | 通过 100 worker 舒适区门槛 |
| 150 | 60s | 6% | 43,738 | 0.00% | 716.06 req/s | 375.03 ms | 485.60 ms | 零错误但进入压力区 |
同轮 `npm run perf:summary -- --allow-writes` 已分别通过 100 worker `P95 <= 300ms/P99 <= 800ms` 和 150 worker `P95 <= 500ms/P99 <= 1200ms` 的容量观察阈值150 worker 不建议作为当前本机 Docker 日常舒适区。
## 初步结论 ## 初步结论
- 本地 Docker 环境下100 个无停顿 worker 内 P95 仍低于 300ms可以作为当前代码和索引状态的本地舒适区参考2026-07-01 最新 100 worker/60s/8% 写入为 838.42 req/s、P95 210.07ms、0 错误。 - 本地 Docker 环境下100 个无停顿 worker 内 P95 仍低于 300ms可以作为当前代码和索引状态的本地舒适区参考2026-07-01 最新 100 worker/60s/8% 写入为 710.38 req/s、P95 257.59ms、0 错误。
- 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms已经能看到本地压力拐点。 - 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms最新 150 worker/60s/6% 写入为 716.06 req/s、P95 375.03ms已经能看到本地压力拐点。
- 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。 - 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。 - 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。

View File

@@ -55,7 +55,7 @@
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
"test:launch-gate": "node scripts/production-launch-gate-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js",
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",

View File

@@ -0,0 +1,305 @@
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)`,
);