mcpField
P
PRD to Flutter APK

PRD to Flutter APK

远程服务
37-27-249-33.sslip.io开发工具110 周前更新

关于 PRD to Flutter APK

这是一个将 JSON 产品需求文档转换为已编译 Flutter 应用的服务,面向需要自动生成应用源代码或 APK 的开发者。它使用多 Agent 流程和真实 Flutter 工具链进行质量检查;可免费生成并查看源代码,APK 通过 Apify Store 获取。

主要功能

  1. 01从 JSON PRD 生成 Flutter 项目
  2. 02多 Agent 循环生成与改进
  3. 03通过真实 Flutter 工具链检查
  4. 04异步构建并查询任务状态
  5. 05生成并提供 APK

适用场景

  1. 01将产品需求快速转为 Flutter 项目
  2. 02离线运行生成流程
  3. 03用 Flutter 分析器和测试检查构建
  4. 04提交构建任务并获取 APK

配置

远程服务无需本地安装。选择你使用的客户端,复制配置或使用一键安装。

服务地址https://37-27-249-33.sslip.io/mcp
一键复制安装命令
Claude CodeTerminal
Cursor.cursor/mcp.json
VS Code.vscode/mcp.json
Claude Desktopclaude_desktop_config.json
Windsurf~/.codeium/windsurf/mcp_config.json

命令在终端中运行;JSON 粘贴到对应客户端的 MCP 配置后重启。需要授权的服务会在首次连接时提示登录。

工具

详细介绍

应用生成微服务

接收 JSON 产品需求文档,并输出编译完成的 Flutter 应用。它基于 LangGraph 循环构建,包含四个子 Agent 和一个运行真实 Flutter 工具链的 QA 检查关卡。

设计目标不是“生成看似合理的代码”,而是零错误编译: 如果输出未通过 CI,循环就算失败。

运行

从 PRD 生成 Flutter APK,发布于 Apify Store — 免费生成并查看源代码;只需为打包后的 APK 付费。除了 Apify 账号外无需其他账号,也无需安装任何内容。

(本 README 的早期版本曾指向 VM 上可自托管的 x402 服务。该部署已退役——源代码和它验证过的支付门控设计没有改变,只是不再运行于那台机器上。)

付费前先看看它生成的内容

examples/generated-field-notes/ 是未经修改的输出——根据旁边的 45 行 todo_app.prd.json 生成的 23 个文件,已提交到代码库,你可以准确查看构建会生成什么。没有为了展示效果而手动修复任何内容。请从 GENERATED.md 开始。

快速开始

无需凭据,也无需 Flutter SDK——默认配置完全离线:

poetry install
poetry run python src/supervisor.py examples/todo_app.prd.json --clean

这会验证 PRD、运行循环,并将 Flutter 项目写入 generated_apps/current_build/。

若要使用真实工具链而非离线模拟桩对输出进行评分:

poetry run python src/supervisor.py --clean --analyzer dart --flutter-root "C:\flutter" --run-tests

作为服务运行

CLAUDE.md 将其称为 M2M 微服务;这是机器买家会调用的部分。

X402_SHARED_SECRET=... FLUTTER_ROOT="C:\flutter"   poetry run uvicorn src.service.app:app --port 8000
端点
POST /builds提交 PRD;返回 202 + 任务 ID,或返回带有 x402 挑战的 402
GET /builds/{id}状态、日志、诊断信息
GET /builds/{id}/apk构建产物
GET /healthz包含支付是否已配置的信息

构建需要几分钟,因此 API 是异步的。提交的 PRD 中的 x402_payment_verified 会被丢弃:PRD 由买家提供,因此信任该字段会让任何人都能声称自己已付款。只有服务端的验证器会设置该字段。

支付采用真实的 x402:买家签署 EIP-3009 TransferWithAuthorization(EIP-712),服务会恢复签名者,检查收款方、金额、链和有效时间窗口,并以原子方式认领 nonce,确保一个签名恰好只能购买一次构建。

X402_TOKEN_CONTRACT=0x036CbD...  X402_CHAIN_ID=84532 X402_PAY_TO=0xYourAddress        X402_PRICE_ATOMIC=500000   poetry run uvicorn src.service.app:app --port 8000

如果不进行任何配置,服务会拒绝所有支付——它会安全地默认拒绝,而不是回退到开发用共享密钥。/healthz 会报告当前启用的模式。

验证不等于结算。 有效签名证明付款人授权了一笔转账;但无法证明其余额充足,也无法证明转账已在链上完成。设置 X402_FACILITATOR_URL 后,服务会提交授权以在链上执行,并在确认前阻止返回 202——因此只有资金实际转移后才会启动构建。未设置时,/healthz 会报告 settlement: verification-only,此时服务接受的是已签署的支付承诺。

认领 nonce 之前,服务会向协调器的 /verify 端点询问该笔支付是否能够结算。余额不足可以补救——付款人可以充值后重新提交同一签名——若因此消耗其授权,就会造成不必要的损失。

结算会区分三种结果,而非两种。拒绝(“余额不足”)是明确结果,不会重试。超时则是未知状态:协调器可能已广播交易但未能响应,因此服务会拒绝构建,同时记录买家可能已被扣款。重试传输故障是安全的——EIP-3009 nonce 在链上只能使用一次,因此重复提交会回滚,而不会重复扣款。

可用的测试网配置

已在 Base Sepolia 上端到端验证:一笔真实交易,一个真实的 APK:

export X402_TOKEN_CONTRACT=0x036CbD53842c5426634e7929541eC2318f3dCF7e  # USDC
export X402_CHAIN_ID=84532
export X402_NETWORK=base-sepolia
export X402_PRICE_ATOMIC=3000000                                      # 3.00 USDC
export X402_PAY_TO=0xYourReceivingAddress
export X402_FACILITATOR_URL=https://facilitator.payai.network         # public, no auth
export FLUTTER_ROOT="C:\flutter"
poetry run uvicorn src.service.app:app --port 8000

付款方只需要 USDC——不需要 ETH。facilitator 提交交易并支付 Gas,这正是 EIP-3009 的意义所在;如果钱包被要求提供测试网 ETH,就说明某处配置有误。水龙头:https://faucet.circle.com。

scripts/check_x402_funding.py 会向 facilitator 的 /verify 发起请求,报告付款方是否有足够资金,而不是引入可能与门禁实际使用的来源不一致的第二个事实来源。

运行多个 worker

设置 REDIS_URL 后,nonce 存储和任务存储都会切换到 Redis;/healthz 会报告 multi_process_safe。nonce 消费使用 SET NX EX,通过一次原子往返操作,确保检查时与使用时之间的保护在多个 worker 之间生效,而不只是局限于单个解释器。Redis 故障时会拒绝付款,而不是允许重放。

在重启后继续运行

资金会在构建开始前上链,因此如果部署导致构建任务丢失,就等于收了款却什么也没做。因此,执行过程本身是持久的,而不只是任务状态:POST /builds 会结算付款、将 PRD 写入任务记录,并把任务 ID 推送到队列。worker 从队列中拉取任务。

BUILD_WORKER_EMBEDDED=0 poetry run uvicorn src.service.app:app --port 8000  # accepts
poetry run python -m src.service.worker                                     # builds

默认情况下,API 进程除了接收请求,也会执行构建,因此单容器部署无需设置这两个标志;当请求处理和构建需要分别扩容时,再设置 BUILD_WORKER_EMBEDDED=0。

worker 会为正在构建的任务持有租约,并在工作期间续约。终止 worker 后,租约会到期,另一个 worker 会将任务放回队列,并根据存储的 PRD 重新构建——这就是为什么要存储 PRD,而不是像执行逻辑仍在接收进程中时那样,将其捕获在闭包中。scripts/verify_durable_execution.py 会通过真实进程和真实的 SIGKILL,准确演示这一过程。

这里有两点是刻意不作保证的。租约只是限制重复工作,而不能阻止它:如果 worker 与 Redis 的连接中断时间超过租约时长,它就无法与已死亡的 worker 区分,因此即使它仍在运行,任务也可能被交给其他 worker。renew 返回 False 时,停滞的 worker 会知道应丢弃结果,但重叠执行确实会发生;它消耗的是计算资源而非资金——付款在任务入队前已结算一次。重试次数也有上限(BUILD_MAX_ATTEMPTS,默认值为 3):否则,一个总会导致 worker 崩溃的构建任务会被重新入队并交给下一个 worker,最终逐个拖垮整个 worker 集群。

变量默认值用途
BUILDS_RATE_LIMIT20每个地址在每个时间窗口内可调用 POST /builds 的次数;0 表示禁用
BUILDS_RATE_WINDOW_SECONDS60该限流规则适用的时间窗口
BUILD_WORKER_EMBEDDED1API 进程是否也执行构建
BUILD_LEASE_SECONDS300无响应的 worker 可保留其任务的时长
BUILD_HEARTBEAT_SECONDS30正在工作的 worker 汇报状态的频率
BUILD_MAX_ATTEMPTS3放弃构建前的最大尝试次数

/healthz 会报告 durable_execution;只有队列和任务存储都由多个进程共享时,该值才为 true——如果队列使用 Redis,而任务存储仍在内存中,构建恢复所依赖的记录就会丢失。

为什么接受请求的端点会受到限流

x402 门禁会阻止任何人免费获取构建,因此这不是为了防盗。问题在于拒绝请求的成本:携带付款标头的 POST /builds 会让服务调用支付协调器的 /verify,然后再调用 /settle;这两个网络往返各有 60 秒超时,却都发生在同步端点上。几百个并发的无效授权请求就足以耗尽线程池,让服务停止响应所有人——包括已经付款的买家。因此,限流只针对这个端点;轮询正在运行的构建和下载 APK 的成本都很低,而且这类请求本来就会频繁出现,限制它们反而会惩罚正常行为。

识别调用方这一步很容易悄无声息地出错。 TLS 代理之后的所有请求,在 compose 网络中都来自 Caddy 的地址,因此如果按套接字对端进行限流,全球所有买家都会被归入同一个桶——限流器看似配置好了,实际却会阻止所有人或不阻止任何人。客户端身份应取 X-Forwarded-For 中最右侧的条目,因为 Caddy 会把它实际看到的对端地址追加到调用方发送的内容后面。更常见的做法是取最左侧的条目,但这样调用方就能自行提供标头,每次请求都伪造一个新身份;有一项测试正是专门确保这种情况会失败。

部署

一个容器负责接受付款并构建 APK,Redis 则让这两项功能都具备持久性。它需要一台带磁盘的 VM:镜像中包含 Flutter、Android SDK 和 JDK,体积达 7.71 GB,因此无法部署到无服务器平台,也不适合大多数 PaaS 免费套餐。

以前,构建一个 144 MB 的 APK 会产生 2.0 GB 的 Gradle 输出,而且这些文件不会被清理——任务记录会在七天后从 Redis 中过期删除,但这删错了对象:记录只有几 KB,而它所指向的目录却有几个 GB。现在,服务会保留构建产物,并在构建完成时立即删除整个目录,而此时工作进程仍持有租约。在部署环境中的测量结果:上一个未清理的构建占用 2.0 GB 磁盘空间,第一个清理过的构建占用 144 MB,而且仍可下载。在一台 150 GB 的服务器上,这意味着大约可以销售 58 次,而不是约 790 次。

cp .env.deploy.example .env.deploy     # fill in X402_PAY_TO and ANTHROPIC_API_KEY
docker compose --env-file .env.deploy up -d --build
poetry run python scripts/verify_deployment.py http://127.0.0.1:8000

docs/DEPLOY.md 是部署手册:说明机器应如何配置以及原因、Docker 发布端口给 ufw 带来的防火墙陷阱,以及正确的操作顺序——先构建,通过 SSH 隧道验证,购买一次真实构建,然后才将服务放到 TLS 保护下的公网地址上。

Dockerfile 中的每个工具链版本都固定为通过上述表格验证的版本——Flutter 3.44.8、Android SDK 36、build-tools 36.0.0、JDK 17。任何版本浮动都会让构建环境变成不断变化的目标,而这是承诺“可以编译”的服务绝不能承受的。 tests/test_deployment_config.py 会在版本发生漂移、compose 中的 ${VAR} 没有对应的配置值,或 builds 卷消失时测试失败。

最后那条命令的重点是部署检查。 容器可以成功启动并响应 HTTP,但仍然无法销售:收了款却从未结算、重启后丢失已付款的构建,或因令牌变量拼写错误而回退到开发环境的共享密钥。/healthz 的设计目的就是用一行信息揭示这些问题,而 verify_deployment.py 一旦发现问题就会拒绝部署。它本身也针对四种故意配置错误的部署进行了验证——没有 Redis、没有支付协调器、没有令牌配置、链错误、没有限流——总共五种情况,并且都能在消息中指出修复方法。--pay 还会使用测试网密钥购买一次真实构建;这是唯一能够证明部署确实可以完成其收费所提供功能的检查。 在将流量导向面向买家的界面之前,有两件事值得了解。服务默认使用 Claude 生成器,因此未设置 ANTHROPIC_API_KEY 的部署会收款,然后构建失败;SUPERVISOR_GENERATOR=template 是免费的确定性路径,也是合理的首次部署选择。而 SUPERVISOR_BUILD_MODE 决定买家获得的是可安装的调试 APK,还是未签名的发布版 APK——下文会说明服务为何不会签名。

镜像现已成功构建并售出。 一台运行 Ubuntu 26.04、配置为 4 vCPU / 8 GB 的 Hetzner 主机首次尝试便成功完成构建;一笔签名的 EIP-3009 授权在 Base Sepolia 上完成结算 (0x0637c94b…),买家通过该服务端到端购得了一个 151 MB 的 APK。

它并非首次尝试就成功,而它失败的方式才是重点。镜像的每一层都构建成功,服务正常启动,/healthz 报告的运行状态正确,部署检查也通过了——但随后,首次付费构建失败了:

PermissionError: [Errno 13] Permission denied: '/opt/flutter/bin/flutter.bat'

Flutter SDK 在所有平台上都会提供 flutter 和 flutter.bat。有四处调用点各自通过选择第一个 exists() 的候选项来查找工具,并且都优先选择 Windows 的命名方式——在这里这项选择是正确的,但在实际要运行它的机器上却是无法运行的批处理文件。299 个测试、一次评估扫描、APK 打包和整张验证表都已在 Windows 上通过,而这个 bug 在 Windows 上不会出现。只有买家付费构建时才发现了它,这恰恰是付费检查要在买家仍是我们自己时触发的失败模式。现在,src/ports/toolchain.py 会统一处理这个问题,并将 windows= 作为参数,因此无论在哪个平台,都可以通过测试覆盖这两个分支。

发布版构建,以及服务为何不会为其签名

flutter create 会创建一个发布版构建类型,并在 // TODO: Add your own signing config 注释下使用 调试 密钥签名。如果不做修改,flutter build apk --release 会成功,生成一个看起来正常的 app-release.apk,可以安装到设备上,却无法发布——Play 会拒绝调试密钥。买家已经付费后,直到上传时才会发现这一点。

因此,--build-mode release 会移除该配置,并生成一个未签名的 APK。

poetry run python src/supervisor.py <prd.json> --execute \
  --build-mode release --flutter-root "C:\flutter" --sdk-root "C:\Android"

服务不会签名,也不持有任何密钥。发布密钥决定了谁可以向应用的已安装用户群推送更新;持有买家的密钥,就意味着掌握了这里生成的每个应用的这种权力,而一次安全漏洞就会让所有应用一并受到影响。买家使用这个服务永远不会接触到的密钥进行签名:

zipalign -p -f 4 app-release-unsigned.apk app-release.apk
apksigner sign --ks my-release-key.jks --ks-key-alias mykey app-release.apk
apksigner verify --print-certs app-release.apk

有两点值得了解。Gradle 编辑是尽力而为的处理,并非保证——对 Flutter 版本迭代中可能变化的模板进行模式匹配,不足以作为安全保障——因此,流水线随后会检查实际生成的 APK;如果无法证明发布版构建未签名,就会失败,而不是将它交给买家。该检查需要 apksigner,因此发布版构建必须指定 --sdk-root 或 ANDROID_SDK_ROOT;如果没有,状态就是 unknown,而未知状态会被拒绝,因为往令人安心的方向猜测,正是导致买家拿到被调试密钥签名却标记为发布版构建的 APK 的原因。

命令

内容命令
构建一个应用poetry run python src/supervisor.py <prd.json>
评测测试(离线,约 1 秒)poetry run python evals/run.py
评测测试(真实分析)poetry run python evals/run.py --analyzer dart --flutter-root "C:\flutter"
评测测试(+ widget 测试)添加 --run-tests
单元测试poetry run pytest
构建 worker(从队列中拉取任务)poetry run python -m src.service.worker
验证构建在 worker 被终止后仍能继续poetry run python scripts/verify_durable_execution.py
验证发布 APK 未签名且可签名poetry run python scripts/verify_release_signing.py
验证生成的应用确实能与 Firestore 通信poetry run python scripts/verify_firestore_roundtrip.py
验证用户可以通过点击进入每个屏幕poetry run python scripts/verify_navigation_flow.py
检查部署是否已配置为出售构建poetry run python scripts/verify_deployment.py <url>
检查 x402 付款方是否已充值poetry run python scripts/check_x402_funding.py

实用标志:--generator {template,claude}、--analyzer {stub,dart}、 --max-repairs N、--build-dir PATH、--clean、--execute、 --build-mode {debug,release}、--sdk-root PATH。

Poetry 可能不在 PATH 中;无论如何,python -m poetry ... 都能正常工作。Flutter SDK 特意未加入 PATH——请传入 --flutter-root 或设置 $FLUTTER_ROOT。

工作原理

plan ──> genui ──> logic ──> qa ──┬──> package ──> END
          ^          ^            │
          └──────────┴── repair ──┘        (bounded by --max-repairs)

每个子 Agent 负责输出中的一个严格划分的部分,不得在其负责范围之外写入—— 此限制由代码强制执行,而非仅写在文档中:

路径所属方
DESIGN.md、pubspec.yaml、analysis_options.yaml规划
lib/ui/**GenUI
lib/providers/**、lib/main.dart逻辑
test/**QA(根据生成的源代码推导)

QA 诊断会路由给实际能够修复问题的 Agent。如果某一轮没有任何改动,路由器会转而升级给另一个 Agent,而不是耗尽整个修复预算,反复运行已经失败的 Agent。

三种生成器

两者都实现相同的 CodeGenerator 接口,因此图不会感知它使用的是哪一种。

  • template(默认)——确定性的 Python 模板。无需网络和凭据。这是已验证的路径。
  • fixture——一个测试替身,可生成正确的 Flutter 代码,但不使用 TemplateGenerator 的任何命名约定。证明评分框架并非只在给自己的输出打分。参见 evals/fixture_generator.py。
  • claude——调用真正的 Claude,并使用结构化输出。在 Opus 5 上通过评测测试验证,结果为 11/11。

实际验证的内容

如实说明状态,因为“能编译”和“能运行”是两种不同的说法:

属性方式状态
PRD 契约Pydantic、引用完整性✅
可编译 / 类型检查通过实际运行 flutter analyze✅ 11/11 个 PRD
符合 PRDsrc/ports/conformance.py✅
无未接线 / 臆造代码分析器 + 一致性检查✅
屏幕在运行时确实能够构建生成的组件冒烟测试✅
修复循环能够如实恢复故障注入✅
x402 门控单元测试✅
EIP-712/EIP-3009 签名验证真实密钥,28 项测试✅
重放保护原子认领,并发竞态测试✅
Facilitator 线路格式实时 facilitator.payai.network✅ 载荷已接受,已恢复付款方
链上结算实时,Base Sepolia✅ 真实交易,约 1.2 秒
链上拒绝重放绕过存储✅ duplicate_settlement
多进程重放安全2 个 uvicorn 工作进程,共享 Redis✅ 跨进程拒绝重放
持久化构建执行构建中途终止工作进程,跨进程✅ 由另一工作进程重新构建
重复工作受限,但不排除停滞时租约到期⚠️ 设计如此——见上文
HTTP 服务端到端测试实时服务,PRD → APK 下载✅
买方无法自行认证付款伪造标记 → 402✅
Claude 生成器——请求格式模拟传输,18 项测试✅
测试框架可用于非模板代码--generator fixture✅ 完整流水线 + APK 路径
Claude 生成器(Opus 5)完整评估扫描✅ 11/11
APK 打包(模板)flutter build apk --debug✅ applicationId 正确
APK 打包(Opus 输出)flutter build apk --debug✅ 145 MB,受 x402 门控
生成的应用能够启动在 Chrome 中执行 main()✅ 每个应用此前都无法启动
真实 Firestore 读写模拟器 + Chrome,生成的应用✅ 模板生成器,仅限 Web
生成的日期字段可往返保留同上✅ 此前无法运行;见下文
每个生成的应用都附带往返测试在 QA 阶段根据 PRD 生成✅ 未在循环中运行——需要模拟器
该测试生成器并非模板专用探测两个生成器的序列化器配对✅ fromSnapshot/toMap 和 fromDoc/toJson
Claude 输出可用于实时 FirestoreOpus 5 应用,模拟器 + Chrome✅ 往返测试通过
针对无人编写的模型代码也能进行发现Opus、Haiku、模板、fixture✅ 四种序列化器形态
已声明的导航确实已接线一致性检查 unreachable_screen✅ 发现 3 个失效路由
导航后目标页面能够渲染应用自身的 main() + Navigator,Chrome✅ 每个 PRD 均生成
用户点击交互入口后能够到达目标页面应用自身的按钮,Chrome + 模拟器✅ 模板输出,3 个操作
点击测试并非装饰隐藏交互入口,其他内容保持不变✅ 恰好是正确的测试失败
针对模型自身组件选择进行同样测试—❌ 尚未在 Claude 输出上运行
填写表单后再点击—❌ 多步骤流程;见下文
Release 构建未签名,而非使用调试签名实际 --release 构建,apksigner✅ 已验证未签名
买方可以为制品签名使用临时密钥库签名✅ 可通过买方证书验证
Play 上传—❌ 需要 Play 账号和应用条目
拒绝配置错误的部署5 个故障服务,真实 HTTP✅ 全部 5 个均被发现,并指出修复方法
接受请求的端点有速率限制限流器 + 端点测试✅ 21 项测试,两种后端
测试套件可在 Linux 上运行,不仅限于 WindowsGitHub Actions 矩阵⚠️ 已编写,尚未执行——没有远程环境
可构建部署镜像Hetzner VM,Ubuntu 26.04,amd64✅ 7.71 GB,一次构建成功
买方付款并收到 APK实时部署,Base Sepolia✅ 真实交易,151 MB APK
工具链可在 Linux 上解析已部署的构建✅ 此前无法运行;见下文
完成的构建不再占用 2 GB实时部署,前后对比✅ 2.0 GB → 144 MB
精简后的构建仍可下载精简后重新获取✅ 200,151 MB
可通过 TLS 在公开地址访问Let's Encrypt,TLS-ALPN-01✅ 受信任的证书,无需隧道
公开互联网中的买方可以购买通过公开 HTTPS 付费运行✅ 已交付 151 MB APK
关于 Redis 的两行,有一点需要说明,因为这里的“✅”确实有实际含义。开发机器上没有 Redis 守护进程,因此两项都通过 fakeredis 验证——单元测试在进程内运行,跨进程持久性测试则通过 TcpFakeServer 运行,它使用真正的 TCP 套接字并传输真正的 RESP。进程边界、套接字和 SIGKILL 都是真实的;实现 LMOVE 和键过期的服务端则不是。只需设置 REDIS_URL,就能通过真实的 Redis 运行 scripts/verify_durable_execution.py,但目前尚未这样做。

这些条目可是费了不少功夫才配得上星号。上面的内容都是静态检查——flutter analyze 证明代码通过类型检查,一致性检查证明它符合 PRD,生成的 Widget 测试证明每个屏幕在覆盖其 Provider 的情况下都能单独构建——但这些都不会运行应用。真正运行后,才发现生成器之前生成的每个应用都有两个 Bug。

应用无法启动。 组合根使用了 await Firebase.initializeApp();,却没有提供任何选项,而且生成器也不会生成 google-services.json。在 Web 上,这会在首帧显示之前触发 firebase_core_web 内部的断言失败;在原生平台上,则没有可回退使用的配置文件。应用成功编译、通过静态分析和 Widget 测试,还打包成了可发布的 APK——却在启动时崩溃。现在,标识符通过 --dart-define 传入;未提供时会传入 null,因此携带原生配置文件的构建仍可正常运行。

日期在读回时会抛出异常。 Firestore 会将 DateTime 存储为 Timestamp,读回时也会返回 Timestamp,因此 data['x'] as DateTime?——虽然 Dart 类型正确——但应用第一次读取自己写入的文档时总会抛出异常。

这两项修复都由测试保护:使用旧输出时测试失败,使用新输出时测试通过。这也是唯一值得信任它们的理由。

现在,这项保护不再依赖手写。QA 阶段会为每个模型生成一个 integration_test/<model>_roundtrip_test.dart,而生成的测试本身也经过同样的验证:使用已修复的映射器时测试通过,使用旧版本时则会因 Timestamp is not a subtype of DateTime? 而失败。

测试会通过应用自己的序列化器写入,再通过应用自己的反序列化器读回,这是唯一不会掉进陷阱的做法。手写文档时必须选择一种线上格式,而两个生成器有意采用了不同做法——TemplateGenerator 将日期存储为 Timestamp,fixture 生成器则存储为 ISO-8601 字符串;两者都正确,因为各自都会读回自己写入的内容。这一对方法也没有任何硬编码:方法名称会从生成的代码中读取,因为 fromSnapshot/toMap 和 fromDoc/toJson 是同一契约的不同写法;如果测试只针对某个生成器的方法名称编写,就会悄无声息地不再测试另一个生成器。

运行测试需要主动选择。它需要模拟器和浏览器;若让分析流程依赖 Node、JDK 21 和 Chrome,就会给每次构建带来很大负担,而大多数构建都无法运行这项检查。

这个 ✅ 也有一项限制:测试是在 Chrome 上运行的,不是在 Android 上——这台机器没有 AVD 或系统映像——这足以验证与平台无关的 Dart 映射器 Bug,却不足以宣称应用能在手机上运行。

现在,Opus 的输出已经通过了这项测试:它生成的模型能在两个方向上正确处理 Timestamp,因此提示词指导确实有效,而不只是写在那里。为此付出了两个 Bug 的代价;两者都出在发现阶段,而且犯的是同一个错误,正是编写此模块要避免的——编写时对当时唯一可用的生成器过拟合。

  • TemplateGenerator 会将每个构造函数参数标记为 required。Claude 会将其中大多数参数设为可选,并提供默认值。只匹配 required this.x 时,找不到任何内容可供断言,因此完全没有生成测试——这种失败看起来和成功一模一样。
  • 反序列化器不必接收 DocumentSnapshot。Opus 会编写 fromMap(String id, Map<String, dynamic> map),让模型不依赖 Firestore 类型;这或许是更好的设计,但也因此无法匹配任何内容。

现在涵盖了四种序列化器形式——fromSnapshot/toMap、fromDoc/toJson、fromFirestore/toFirestore、fromMap/toMap——并覆盖两种调用参数数量。这个仓库反复吸取的教训是:编写检查时若只参考一个生成器,检查中就会编码那个生成器的偏好;要知道检查是否有效,唯一办法就是用别人编写的代码来运行它。

无法抵达的路由

PRD 将导航声明为数据——Action(kind="navigate", target=<screen>)——而且 schema 已经会拒绝指向不存在屏幕的目标。生成器列出了路由表中的所有这些屏幕,却从未推送任何路由。/capture 和 /settings 都存在,但没有任何 widget 能到达它们:这个应用只是一张无人能打开的表单。

所有检查都通过了。每个屏幕都能独立构建,因此冒烟测试全绿。路由表是有效的 Dart 代码,而分析器不会判断某个路由是否真的被导航到。符合性检查确认屏幕确实存在,而它们的确存在。

unreachable_screen 解决了这个问题,并且刻意不限制屏幕的抵达方式——命名路由、用于构建 widget 的 MaterialPageRoute,或路由器的 go() 都算。它不会接受的情况是:目标只出现在声明该目标的路由表中,而这正是那个 bug 的表现形式。

有两点值得记录。第一版检查是空洞的:它会在每个 UI 文件中搜索 CaptureScreen(,结果在目标屏幕自己的文件中找到了它自己的构造函数,于是每个无法抵达的屏幕都看起来可以抵达。直到用实际出错的输出而非 fixture 运行检查,这个问题才被发现。检查生效后,它立刻又找到了两个之前无人查看的实例——auth_flow 和 many_screens——因为修复只覆盖了列表屏幕,而身份验证、设置和详情屏幕都没有可供导航挂载的入口。

unreachable_screen 提供的是静态保证:应用中存在导航到每个已声明目标的代码。它并不说明目标页面能否正常工作,也无法做到这一点——无论 CaptureScreen 是否能成功构建,'/capture': (context) => const CaptureScreen() 都能满足这项检查。

因此,QA 阶段还会生成 integration_test/navigation_test.dart,通过应用自己的 main() 启动应用——使用真实的组合根、真实的 ProviderScope 和真实的 Firebase 初始化——再驱动应用自己的 Navigator 导航到每个已声明目标,并检查目标页面是否显示在屏幕上。从生成的应用中删除某条路由,测试就会报错,提示 Could not find a generator for route RouteSettings("/capture");这也证明它确实在测试。

目标屏幕通过其PRD 标题来识别;无论生成器如何命名类、文件或路由,这个标签始终不变。匹配 <Id>Screen,等于匹配某个生成器的约定。

点击按钮,以及我们如何确认这个测试确实有效

驱动路由器仍然没有让用户参与其中。unreachable_screen 说明源代码中有内容会导航到每个目标;推送测试说明抵达后目标页面会渲染。两者都无法说明,查看源屏幕的人是否有可点击的内容。

第一版检查拒绝继续推进,理由是查找按钮取决于 widget 的选择,而且会导致测试不稳定。不过,三个决定让它变得可靠:

  • 可点击对象不只是一个按钮列表。 InkResponse 位于 TextButton、ElevatedButton、IconButton、FloatingActionButton 和 ListTile 下方;其余情况则由 CupertinoButton 和手写的 GestureDetector 覆盖。枚举按钮类只会体现 TemplateGenerator 的偏好, 还会漏掉最常见的交互方式:生成的列表屏幕会从 FAB 导航,而 FAB 唯一的子项是图标,因此根本没有标签。
  • 每次尝试都会从重新推入的源屏幕开始, 将“不断点击,直到发生某些事”从一个有状态的序列变成 N 个独立的单次点击实验。这一点很重要:生成的 FAB 会在导航前写入草稿文档,因此如果不重置,每次尝试面对的屏幕都会与上一次不同。
  • 到达目标时,先看路由名称,再看标题。 按钮的标签与目标标题相同,是不错的 UI;如果“到达”意味着“标题出现在屏幕上”,那么测试会在点击任何内容之前就通过——所以只有当标题此前并未显示时,才算到达。

这些都不是证据。scripts/verify_navigation_flow.py 才是:它会生成包含八个屏幕的 many_screens 应用,在 Chrome 中针对 Firestore 模拟器运行导航测试,然后再针对一个变异版本运行一次,该版本将一个交互对象包装在 Offstage(offstage: true, ...) 中。

选择这种变异,是因为它不会影响其他功能。Navigator.pushNamed 调用仍然保留在文件中,因此 unreachable_screen 仍会通过。路由表没有改动,因此 push 测试仍会通过。Widget 仍在树中,因此构建不会有任何变化。它只是永远不会显示在屏幕上——应用随附了一个用户无法打开的屏幕,而所有静态检查仍然全绿:

Failure in method: openToday: a tap on inbox reaches today
  Expected: empty
    Actual: 'tapped 2 of 2 affordance(s) on /inbox and none of them reached /today'

六个生成的测试用例中,恰好只有一个失败,而且失败得恰到好处。这是我们相信点击测试确实测到了东西的唯一理由。

它仍无法证明多步骤流程能够到达目标。只有在凭据有效时才会导航的登录屏幕,需要先点击再输入一些内容,而 PRD 并未说明要输入什么。这些测试会在此处失败,而不是被悄悄跳过,这个方向是对的——但这意味着点击测试失败表示“请查看这里”,而不自动意味着“应用有问题”。

而且它只在模板输出上运行过。 这个仓库里的每个过拟合 Bug,在别人编写的代码通过检查之前都无法发现——往返发现机制曾因此两次失效。交互对象搜索的编写方式不依赖于它检查的是谁的 Widget,这种说法恰恰是我们过去在这里犯过错的地方。值得接下来投入精力的,是在此脚本中使用 --generator claude。

自动化需要自己的浏览器。 普通 Chrome 会话正在运行时,flutter drive -d chrome 无法获取可控制的 Chrome——启动操作会委托给现有实例,调试端口不会打开,dwds 在大约 25 秒后会因 AppConnectionException 失败。这看起来像是代码故障,实际并非如此。将 CHROME_EXECUTABLE 指向一个版本与 chromedriver 匹配的 Chrome-for-Testing 二进制文件:

npx @puppeteer/browsers install [email protected] --path ~/chrome-for-testing
export CHROME_EXECUTABLE=~/chrome-for-testing/chrome/win64-150.0.7871.124/chrome-win64/chrome.exe

除非通过 --driver-port 指定其他端口,否则 flutter drive 也会要求 WebDriver 使用 4444 端口;如果 chromedriver 使用其他端口,报错信息会提到 4444,而不是实际指定的端口。

339 个单元测试;针对真实分析和 Widget 测试的评估横向检查为 11/11; 根据经过付款验证的 PRD 端到端构建出的调试 APK。

目录结构

src/
  supervisor.py        CLI entry point
  prd/schema.py        the PRD contract — strict, validates referential integrity
  graph/               LangGraph wiring, shared state, the five nodes
  ports/
    generator.py       CodeGenerator protocol
    templates.py       deterministic generator (validated)
    llm.py             Claude generator (Opus 5, validated)
    analyzer.py        StubAnalyzer (offline) + FlutterAnalyzer (real)
    conformance.py     does the app match the PRD?
    smoke.py           generates widget tests from the output
    roundtrip.py       generates the Firestore round-trip test from the output
    navigation.py      generates the runtime navigation test: push and tap
    runtime.py         runs them
    ownership.py       which agent can fix which diagnostic
  payments/x402.py     the payment gate + x402 verifier selection
  payments/eip3009.py  EIP-712 signature recovery and field checks
  payments/replay.py   single-use nonces, atomic claim
  service/
    app.py             FastAPI app; accepts and pays, then queues
    jobs.py            the job record (including the PRD) and its store
    queue.py           reliable queue: atomic reserve, leases, requeue
    worker.py          pull-based workers; heartbeats and bounded retries
  build/pipeline.py    APK packaging (x402-gated)
  build/signing.py     unsigned release builds, and proving they are unsigned
  build/scaffold.py    generates android/ via `flutter create`
evals/
  prds/                10 PRDs chosen to break things
  run.py               pass-rate harness
emulator/              Firestore emulator config (a demo-* project; no keys)

说明和限制

  • 转义至关重要。 PRD 文本会进入两种生成语言, 每个边界都需要单独转义:yaml_scalar 用于 pubspec.yaml, dart_string 用于 Dart 字面量。两者都曾在测试中出错,因此都不可或缺。
  • Firestore 模拟器需要使用与构建不同的 JDK。 firebase-tools 不接受低于 JDK 21 的版本;此处的 Android/Gradle 工具链 固定为 17。二者可以共存——只需为模拟器单独提供 21+ 版本的 PATH,其他设置不变—— 但报错看起来像是 Java 问题,而非版本策略问题。如果脚本搜索不到 21+ 版本,请设置 EMULATOR_JAVA_HOME。 此外,还需要 firebase-tools 和主版本号与已安装 Chrome 相匹配的 chromedriver。
  • stub 分析器无法解析 Dart。 它会强制检查约定并检测 provider 不同步, 但它曾让一个存在 59 个语法错误的项目通过。离线运行显示通过只能作为冒烟测试信号,而非证明;要验证这一点,请使用 --analyzer dart。
  • **Cupertino 是一整套模板系列,**并非替换根组件即可—— CupertinoApp 不提供 MaterialLocalizations,因此其中的 Material 组件会在运行时抛出异常。
  • 冒烟测试使用 stub provider。 它们能证明屏幕可以构建且其数据层可以解析;但无法证明导航有效,也无法证明写入操作能够到达 Firestore。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。