关于 test-native-extension
用于第三方原生控件仓库的测试技能,自动执行原生代码、清单和 PCF 的结构与契约检查,并提供设备端手动测试步骤。它不编译原生二进制,自动检查在首次失败处停止。
主要功能
- 01跨原生代码、清单与 PCF 检查一致性
- 02验证原生模块结构和符号
- 03检查 manifest 规则
- 04存在 PCF 时运行构建
- 05输出设备端测试步骤
适用场景
- 01原生模块改动后进行结构预检
- 02发现原生代码与 manifest 或 PCF 的契约偏差
- 03编译 PCF 并准备设备端验证
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ power-platform-skills --skill test-native-extension安装后在 Claude Code 中输入/test-native-extension确认已加载。
详细介绍
/test-native-extension
运行针对第三方控件仓库的 4 层验证阶梯——该仓库打包为 .ppmplugin 二进制包,而非 TypeScript 扩展。第 1–4 层为自动化验证;第 5 层为交互式验证(需要真实设备或模拟器,并且 Companion PCF 已部署到测试环境)。
| 层级 | 内容 | 模式 | 速度 | 要求 |
|---|---|---|---|---|
| 0 | 全面检查契约一致性(native ↔ manifest ↔ PCF 交叉核对) | 自动化,仅警告 | 数秒 | 磁盘上至少有一个 native 模块 |
| 1 | Native 源代码结构断言(Android getName() ↔ iOS +moduleName ↔ manifest) | 自动化,grep/parse | 数秒 | android/ 和/或 ios/ |
| 2 | Manifest 验证(ppmplugin-format §4 规则) | 自动化 | 数秒(仅在磁盘上没有 manifest 时跳过) | ./manifest.json(已提交;否则使用暂存副本) |
| 3 | Native 源代码契约断言(请求/响应/错误 grep 交叉核对) | 自动化 | 数秒 | native 模块 |
| 4 | PCF 编译(在 pcf/<Pascal>PCF/ 中运行 npm run build) | 自动化 | 数秒(首次安装后) | 必须存在 pcf/<Pascal>PCF/(否则跳过) |
| 5 | 手动设备 / 模拟器端到端验证 | 仅提供操作步骤——技能会打印步骤,用户自行运行 | 5–10 分钟,不在技能运行期间 | 必须存在 pcf/,且已部署 PCF |
第 1–4 层按层级顺序运行。自动化层中首个失败即停止。技能不会阻止进入第 5 层——它会打印设备操作步骤后退出;用户可自行安排时间运行,并手动更新 .extension-state.md。
此技能不会验证的内容: 将 native 代码编译为可加载的 DEX / framework。这由
/build-android-binary和/build-ios-binary负责——它们会使用固定的 RN 版本运行真实的 Gradle / xcodebuild 工具链,并显示实际的编译器错误。从此技能中单独运行pod lib lint和./gradlew assembleDebug会造成错误的信心(它们从公共 CDN/maven 解析依赖,而不是使用 wrap host 固定的版本)。此技能是结构性预检,无需工具链,只需数秒即可完成——它会断言 native 源代码的结构正确(基类正确、符号正确、AndroidgetName()↔ iOS+moduleName↔ manifest 一致),避免构建技能在后期才因可在数秒内修复的错误而失败。此流程中没有 TypeScript /INativeExtension层可供类型检查——仅含 native 的.ppmplugin包会直接分派到NativeModules.<nativeModule>.<method>(ppmplugin-format §2— 运行时分派契约)。
步骤 1 — 阅读共享文档和 PRD
-
阅读
shared/shared-instructions.md、shared/naming-conventions.md、shared/ppmplugin-format.md。 -
应用按技能执行的最小前置条件策略(
shared-instructions.md §1.5)。第 1–3 层无需工具链(只需读取、grep,并根据工作树进行验证)。仅当存在 PCF 时,第 4 层才需要 Node + npm,而且只在首次运行时需要(从公共 npm 注册表运行npm install,安装 PCF 自身的依赖项)。此流程自成一体,无需软件包源或源代码管理身份验证(shared-instructions §0a)。运行prereq-check.md中的/test-native-extension检查(第 0–3 层无需任何工具;只有第 4 层存在 PCF 时才需要 Node + npm——此自成一体的流程中没有“基线”检查)。继续之前,按照
shared-instructions.md §9.2将前置条件状态打印为可见区块:━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Prereq check — /test-native-extension ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🟢 ✓ git installed 🟢 ✓ Node 20+ installed (only needed for Layer 4 — PCF compile) 🟢 ✓ npm installed (only needed for Layer 4 — PCF compile) 🟢 3 checks passed. Ready to proceed.如果不存在
pcf/,则完全不需要 Node/npm——将它们标记为n/a (no PCF),不要因此判定失败。第 1–3 层始终运行。如果任何检查失败,打印该检查对应的→ Fix:行并停止。 -
阅读
./PRD.md。如果文件缺失,第 3 层的契约断言将回退到以原生源代码本身作为事实来源(仍然有用)——记录这一点并继续,不要停止。 -
阅读
./.extension-state.md。如果 Phase 低于manifest(磁盘上尚无原生模块),则停止——没有已搭建的内容可供测试。
与操作系统无关——请使用内置的 Read/Grep 工具运行这些检查,而不要使用 shell。 此技能中的每个提取/断言(下方的
find/grep/sed/awk片段)都仅为方便阅读而以 bash 展示——它描述的是要匹配什么,而不是要执行的 shell 命令。请使用 Agent 内置的 Read 和 Grep 工具(以及你自己的解析方式)运行,这些工具在 macOS、Linux 和 Windows PowerShell 上的行为完全一致。不要调用 shell 来运行grep/sed/awk/sort/find——这些工具在原生 Windows 环境中并未预装,而且第 0–3 层特意设计为只读、解析(无需工具链),因此可以在任何环境中运行。第 4 层的npm run build是唯一真正的命令,而 npm 支持跨平台运行。
步骤 2 — 与用户确认范围
首先,自动检测磁盘上是否存在 PCF companion — 使用 Grep 工具(glob: pcf/**/ControlManifest.Input.xml),避免大小写差异和轻微的目录布局变化导致检测失败。(以下 bash 仅供说明 — 不要在 Windows 上原样运行):
PCF_MANIFEST=$(find pcf -type f -name "ControlManifest.Input.xml" 2>/dev/null | head -1)
[ -n "$PCF_MANIFEST" ] && PCF_PROJECT_ROOT=$(dirname $(dirname "$PCF_MANIFEST"))
如果设置了 $PCF_MANIFEST,则 PCF 已搭建;在第 4 层的构建步骤中使用 $PCF_PROJECT_ROOT(例如 pcf/<Pascal>PCF)。如果为空,则区分以下情况:完全没有 pcf/ →“尚未搭建”;存在 pcf/,但没有清单 →“搭建似乎未完成;请重新运行 /generate-pcf-companion 或检查文件夹。”
同时检测驱动第 2 层的清单。优先使用已提交的 ./manifest.json(这是 /generate-native-extension 在搭建时写入的事实来源,因此通常在搭建后就已存在),否则回退到暂存的构建副本:
MANIFEST=$( [ -f ./manifest.json ] && echo ./manifest.json || ls ppmplugin/staging/manifest.json 2>/dev/null )
如果 $MANIFEST 为空,则跳过第 2 层(磁盘上尚无清单 — 这是尚未运行 /generate-ppmplugin-manifest 的手动编写模块;使用搭建流程时,./manifest.json 从一开始就会存在)。
这会决定第 4 层(PCF 编译)和第 5 层(手动操作步骤 — 两者都要求 PCF 已存在)的运行情况。
| 状态 | 默认层级集合 |
|---|---|
没有 pcf/ 文件夹(PCF 尚未搭建) | 运行第 1、2、3 层;第 4、5 层标记为延期 — 在 /generate-pcf-companion 之后重新运行 |
存在 pcf/ 文件夹 | 自动运行第 1–4 层;第 5 层打印设备操作步骤(不等待,也不设门槛) |
打印:
Test plan
─────────
Repo: <cwd>
Extension: @powerapps/extension-<kebab> (class <Pascal>Extension, module <Pascal>Module)
PCF companion: <found pcf/<Pascal>PCF/ | NOT yet scaffolded>
Manifest: <found ./manifest.json (committed) | ppmplugin/staging/manifest.json (staged) | NONE>
Automated layers (skill runs these and reports pass/fail):
0. Holistic contract check — native ↔ manifest ↔ PCF cross-grep (warn-only)
1. Native-source structure — getName()/+moduleName/@ReactMethod asserts + load/init readiness (no-arg package ctor, iOS [cls new]/requiresMainQueueSetup, non-throwing construction)
2. Manifest validation — <ppmplugin-format §4 rules | SKIPPED — no manifest>
3. Native-source contract — request/response/error grep cross-check
4. PCF compile — <npm run build in pcf/<Pascal>PCF | DEFERRED>
Manual layer (skill prints a recipe; you run it on a device on your own time):
5. Device end-to-end — <print recipe | DEFERRED>
Not validated by this skill: native iOS / Android compile into a loadable DEX / framework (run /build-android-binary // /build-ios-binary for that).
Stop on first failure: <yes by default>
使用 AskUserQuestion:
运行测试计划吗?
- 运行上述默认集合(推荐)
- 仅运行第 1–3 层(跳过 PCF 编译;跳过第 5 层操作步骤)
- 运行单个层级(指定层级)
- 取消
如果用户选择单个层级,请验证依赖关系 — 例如:“第 3 层(契约断言)在本次或近期运行中通过第 1 层(结构断言)后会更有用;是否要跳过检查并继续运行?”。不要强制执行;说明可能产生的影响,并由用户决定。
步骤 2.5 — 第 0 层:整体契约一致性
诊断层,不是硬性门槛。 报告发现的问题;将其降级为警告,而不是阻止继续执行。适用于在第 5 层出现更难调试的运行时症状之前,发现原生模块 ↔
manifest.json↔ PCF 之间的偏差。
此层会交叉检查原生模块、暂存的 manifest.json 和 PCF 中的契约。这些检查都不会编译或运行代码 — 全部基于 grep/解析。如果任何检查发现不匹配,该技能会打印编号警告,列出不匹配的值和建议的修复方法,但会继续进入第 1 层,除非用户选择停止。这样做是为了尽早发现不一致;由工程师判断哪些问题需要处理。
检查内容
| # | Contract | Sources cross-checked | Mismatch surfaces |
|---|---|---|---|
| 1 | 路由键 | manifest receivers[].name → pcf/<Pascal>PCF/<Pascal>PCF/index.ts 中的 ROUTING_KEY 常量 | 两者必须是同一个接收器键(我们的脚手架使用 '<Pascal>Extension';任何 JS 标识符均可,参见 ppmplugin-format §2)。不匹配会导致运行时静默路由失败(封装桥接无法分发)。 |
| 1b | PCF 传输(线格式) | index.ts 中的分发调用:必须调用 window.PowerApps.NativeExtension.sendAsync("<key>", { method, args: [request] }),不得直接调用 cordova.exec(或任何 cordova.*);sendAsync 负载必须是原始对象(不能预先执行 JSON.stringify——sendAsync 会在内部将其字符串化),内部的 args 必须是 [request](一个数组)。在文件中搜索,确认存在 \.sendAsync\( 且不存在 cordova\.exec。 | 这是唯一会对应到线格式错误的结构性检查。直接调用 cordova.exec 会通过其他所有检查(数组确实是数组,键确实对齐),但会在设备上第一次轻触时失败——PCF 沙盒中没有原始的 cordova 全局对象,因此轻触不会有任何反应(Android 上最明显)。同样,预先字符串化的负载会导致双重编码 → BRIDGE_FAILED。如果缺少 sendAsync 或存在 cordova.exec → 醒目标记(视为优先级最高的 Layer 0 发现),并指向 /generate-pcf-companion 和 ppmplugin-format §2。 |
| 2 | 原生模块符号 | iOS 类名 RCT<Pascal>Module == manifest entrypoints.ios.moduleClass;iOS +moduleName 返回值 → Android override fun getName() = "<X>" → manifest receivers[].nativeModule | +moduleName、getName() 和 receivers[].nativeModule 必须使用相同的 '<Pascal>Module'(规范前缀规则,ppmplugin-format §3)。Obj-C 类名是一个单独的值,必须与 entrypoints.ios.moduleClass 相等。不匹配会导致 JS 侧分发在一个平台上找不到目标,但在另一个平台上正常工作;或者验证器拒绝该 manifest。 |
| 3 | 操作方法名称 | PRD §4 表格 → RCT_EXPORT_METHOD(<methodName>:...)(iOS)→ @ReactMethod fun <methodName>(...)(Android)→ manifest receivers[].methods[] | 四处必须匹配。可发现虽能编译、却无法路由的拼写错误,以及主机永远无法分发的、缺失于 methods[] 中的方法。 |
| 4 | 请求字段名称 | ARCHITECTURE §4.1 → iOS 解析器(.m 中的 NSDictionary 键读取)→ Android 解析器(.kt 中的 ReadableMap 键读取)→ PCF 负载构建(index.ts 中的 request.<field>) | 四处必须引用相同的字段名称。仅 iOS 或仅 Android 不匹配会导致平台特定的 INVALID_INPUT。 |
| 5 | 响应字段名称 | ARCHITECTURE §4.2 → iOS JSON 构建(successJsonWith: 中的键)→ Android JSON 构建(successJson 中的键)→ PCF 响应读取(index.ts 中的 response.result.<field>) | 四处必须匹配。可发现“PCF 读取到 undefined”问题。 |
| 6 | 错误代码 | ARCHITECTURE §5 错误代码联合类型 → iOS errorJsonWithCode:message: 参数字符串 → Android errorJson(code, message) 参数字符串 → PCF setError case 标签 → ARCHITECTURE §8 中存在对应行 | §5 中的每个代码都应至少出现在一个原生发送位置,并且 PCF setError 应有对应的 case(或 default)。原生代码发送了但 §5 未列出的代码 → 警告(PRD 漂移)。§5 中存在但 PCF default 未处理的代码 → 警告(覆盖不完整)。 |
| 6b | 错误 MESSAGE 传递 | 原生错误辅助函数签名包含 message(errorJsonWithCode:message: / errorJson(code: String, message: String))→ PCF setError(code, message) 接收两个参数并设置 this.errorMessage → ControlManifest.Input.xml 声明 Status + ErrorCode + ErrorMessage,且 usage="output" → getOutputs() 返回全部三项 | 原生辅助函数仍然只接收 code、setError 只接收一个参数,或缺少 ErrorMessage 输出 → 警告:失败信息到达制作者时将只有代码(或什么都没有),缺少人类可读的原因——此检查旨在弥补设备端调试的信息缺口。 |
| 7 | PCF manifest 属性 ↔ index.ts | pcf/<Pascal>PCF/<Pascal>PCF/ControlManifest.Input.xml 中的 <property name="..."> → index.ts 中的 IInputs / IOutputs 引用(通过 p.<Name>.raw 和 getOutputs() 返回键) | 应读取所有 manifest 属性;所有 IOutputs 键都应出现在 getOutputs() 中。Layer 4 的 tsc 会实际强制执行这一点——Layer 0 会更早以更易读的差异形式呈现。 |
| 8 | 权限 ↔ §3.2 | ARCHITECTURE §1.4 → iOS Info.plist 使用说明字符串(例如 NSCameraUsageDescription)→ Android AndroidManifest.xml 中的 <uses-permission> 条目 | ARCHITECTURE §1.4 中的每一行都应有对应的原生条目。不匹配会导致操作系统在运行时拒绝权限,且不向用户显示消息。 |
| 9 | RN 固定版本 | package.json devDependencies 中的 React Native 固定版本 → android/build.gradle 中的 compileOnly RN 行 → manifest abi / 构建固定版本(ppmplugin-format §0 常量) | 所有版本都应与封装主机的 RN(0.79.7)匹配。版本漂移意味着二进制文件针对不同于主机加载时所用的 RN 进行编译——设备上会发生静默的 ABI 不匹配。 |
| 10 | 未解析的原生引用(启发式) | 扫描 ios/ 和 android/ 中的 *.kt、*.m、*.swift 文件。对于每个函数/方法调用位置,确认其在同一文件中有定义,或在顶部有匹配的 import / #import。 | 在 /build-android-binary // /build-ios-binary 编译发现之前,提前标记 Unresolved reference 错误。过去曾发生过调用辅助方法却未实际定义的回归问题(例如 createTopNavBar()),在此处即可发现。 |
| 11 | 不得泄漏 SDK 时代内容(/audit-ppmplugin 的拒绝名单镜像) | 对原生源代码 + package.json(不是 PCF)执行 grep,查找属于已弃用 TS 扩展模型的符号:导入 INativeExtension / implements INativeExtension、sendAsync 传输调用、handleMessageAsync 入口点、extensionClassName / jsLayer 字段,或 Android 模块上的 @ReactModule 注解。 | .ppmplugin 仅包含原生二进制文件,并直接分发至 NativeModules.<nativeModule>.<method>——这些符号都不应出现在随包交付的内容中。若在原生源代码 / package.json 中发现任何一项 → 警告(在 /audit-ppmplugin 阶段会是严重的 CRITICAL,因此现在就修复)。注意:PCF 中的 sendAsync(pcf/…/index.ts)是正确且必需的——泄漏扫描仅针对原生/包内容源,绝不扫描 PCF。参见 ppmplugin-format §6(此格式不涵盖的内容)。 |
| 12 | 构造函数 / init{} 安全性(启动崩溃 lint) | **将扫描范围限定在模块的构造闭包,而不是整个文件:**主/次构造函数 + init{} 代码块 + 属性初始化器(构造时运行的 private val x = …),以及它们调用的任何私有函数(跟进一层 foo() / this.foo())。在此闭包内标记:register*Callback(…, null)、不带显式 Looper 的裸 Handler() / Handler(...),以及任何未用 try { } catch 包装的副作用调用(register* / add*Listener / observe / getSystemService 后使用 / 文件或网络 I/O / runBlocking)。@ReactMethod 方法体不在范围内(它们按调用运行,而不是在构造时运行)。 | 模块会在桥接启动时立即构造,且所在线程可能没有 Looper——若此处发生未捕获异常,主机就会在启动时、任何 UI 出现之前崩溃(ppmplugin-format §5)。发现任何问题 → 警告:延迟到首次调用时再初始化,传入 Handler(Looper.getMainLooper()),并用 try/catch 包装无法避免的初始化。**iOS 对应规则:**同样适用于会抛出异常/执行繁重操作的 init(模块通过 [cls new] 立即实例化,§5b)。此处为启发式检查——权威静态检查现在是 Layer 1 的 Load & initialization readiness 断言(对清除触发条件及 ReactPackage 无参构造函数 / iOS [cls new] / requiresMainQueueSetup 加载检查进行硬性门控);并镜像为 /audit-ppmplugin src-ctor-no-throwable-sideeffects;运行时检查则是 Layer 5 的启动崩溃扫描。 |
| 13 | PCF 解包响应容器 | 在 pcf/<Pascal>PCF/<Pascal>PCF/index.ts 中,invokeBridge / sendAsync 成功路径必须执行类似 extractResponse 的解包(解析 result.data 并探测 message 容器),不能只进行一次简单的 JSON.parse。对 sendAsync 结果路径进行模式匹配,确认存在 extractResponse( 调用(或内联的 "message" in 解包)。 | 封装传输会将模块的 JSON 嵌套在 message 键下({isUpdate, message});如果 PCF 只解析一次,就会停留在容器上,导致每次调用都以 UNEXPECTED_PAYLOAD 失败,尽管原生端已成功(ppmplugin-format §2)。缺少解包 → 警告。对应 /audit-ppmplugin Category F pcf-response-unwraps-message。 |
| 14 | 监听器 / 资源泄漏(注册后未释放) | 对模块中的每个 register* / add*Listener / observe / 通过 getSystemService 获取的资源,检查是否在 invalidate() / onCatalystInstanceDestroy() / 拆卸路径中有对应的释放操作(unregister* / remove*Listener / .close() / .release())。 | 未释放已注册的回调或已获取的管理器会在模块生命周期结束后发生泄漏,并可能向已失效的模块触发调用。缺少释放操作 → 警告:在 invalidate() 中注销。 |
| 15 | Promise 始终会得到处理(挂起防护) | 每个接收 Promise / resolver+rejecter 的 @ReactMethod(Android)/ RCT_EXPORT_METHOD(iOS),必须至少在一个可达路径上包含 promise.resolve / promise.reject(或 resolve(...) / reject(...))。 | 方法返回时从未处理 Promise,会使制作者面对一个挂起的控件,且没有代码/消息。接收 Promise 但没有任何 resolve/reject 调用位置的方法 → 警告(必然挂起)。 |
| 16 | 已声明危险权限但未检查 | 如果 AndroidManifest.xml 声明了危险权限(CAMERA、RECORD_AUDIO、ACCESS_FINE/COARSE_LOCATION、READ/WRITE_EXTERNAL_STORAGE、READ_CONTACTS 等),模块源代码必须引用 checkSelfPermission / ContextCompat.checkSelfPermission / 权限请求。 | 在 API 23+ 中,仅在 manifest 中授予权限并不足够——不进行运行时检查就调用 API 会抛出 SecurityException。已声明但未检查 → 警告:检查权限,并在被拒绝时处理 PERMISSION_DENIED。 |
| 17 | currentActivity 空值保护 | 模块中每次使用 currentActivity 时都必须进行空值保护(currentActivity ?: return … / currentActivity?. / 显式 == null 检查)——标记裸用的 currentActivity!! 或 currentActivity.<member> 解引用。 | 应用在后台运行时,currentActivity 为 null;未保护的解引用会因 NPE 导致主机崩溃。未保护 → 警告:添加保护,并处理 NO_ACTIVITY。 |
运行方式
对于每项检查,技能都会执行一系列 grep/读取/比较操作:
# Example for check #1 (routing key)
PRD_CLASS=$(grep -oE "Class name \(Pascal\) \| .+" PRD.md | sed 's/.* | //')
MANIFEST_KEY=$(grep -oE '"name"\s*:\s*"[^"]+"' "$MANIFEST" | head -1) # first receiver name ($MANIFEST = ./manifest.json or staged copy)
PCF_KEY=$(grep -oE 'ROUTING_KEY = "[^"]+"' pcf/${PRD_CLASS}PCF/${PRD_CLASS}PCF/index.ts)
# Compare; if mismatch, print:
# ⚠️ Layer 0 check 1 (Routing key): manifest says '<X>', PCF says '<Z>'
# Fix: align both to '<expected>'
对于检查 #10(未解析的原生引用),使用启发式 grep 流程:
# For each .kt file in android/, build the set of in-file definitions + imports,
# then for each call site, check membership.
for kt in $(find android/src -name "*.kt"); do
IN_FILE_FUNS=$(grep -oE 'fun\s+[a-zA-Z_][a-zA-Z0-9_]*' "$kt" | awk '{print $2}' | sort -u)
IMPORTS=$(grep -oE '^import\s+[a-zA-Z0-9_.]+(\.[a-zA-Z0-9_*]+)?$' "$kt" | awk '{print $2}' | awk -F. '{print $NF}' | sort -u)
# Call sites: identifiers followed by `(`, excluding keywords + same-line definitions
CALL_SITES=$(grep -oE '\b[a-zA-Z_][a-zA-Z0-9_]*\(' "$kt" \
| sed 's/($//' \
| grep -vE '^(if|when|while|for|return|require|listOf|arrayOf|mapOf|setOf|Pair|Triple|let|run|with|apply|also|takeIf|takeUnless)$' \
| sort -u)
# Flag any call site not in IN_FILE_FUNS or IMPORTS or known Android/Kotlin builtins
...
done
(.m/.swift 文件也采用相同模式,只是会调整正则表达式,以匹配 Obj-C 选择子/Swift 函数声明。)这是启发式方法——无法完美区分对已导入类型成员的调用与未定义函数调用——但能捕捉最常见的情况(调用了 createTopNavBar(),却没有任何地方定义 fun createTopNavBar,也没有可能提供它的导入)。
该技能会运行全部十七项检查,汇总检查结果,并在该层末尾以编号列表形式打印。
通过
所有检查在原生模块、清单和 PCF 中均一致。打印 ✓ Layer 0 (contract consistency): pass — <ISO time>。
警告(继续到 Layer 1,不会导致运行失败)
一项或多项检查发现不匹配。打印:
⚠️ Layer 0 (contract consistency): <N> warning(s)
1. Routing key mismatch:
- manifest receivers[].name: '<Pascal>Extension' ✓
- pcf/.../index.ts uses: '<Pascal>' ✗ — fix this
Suggested fix: in pcf/<Pascal>PCF/<Pascal>PCF/index.ts line N, change ROUTING_KEY to "<Pascal>Extension"
2. Response field name mismatch:
- ARCHITECTURE §4.2 expects: 'signatureBase64'
- ios/.../Module.m emits key: 'result' ✗ — should be 'signatureBase64'
- android/.../Module.kt emits key: 'result' ✗
- pcf/.../index.ts reads: response.result.signatureBase64
Suggested fix: ARCHITECTURE §4.2 and the native emit sites disagree. Either update the native modules to emit 'signatureBase64', or update ARCHITECTURE §4.2 + the PCF read to use 'result'.
...
由用户决定是继续前修复(修复后重新运行),还是继续到 Layer 1(确认存在偏差)。使用 AskUserQuestion:
Layer 0 发现 处契约不一致。是否继续?
- 继续到 Layer 1 — 将警告记录在
.extension-state.md中,但不会阻止运行- 在此停止,先修复警告 — 退出;用户修复后重新运行
失败(停止运行)
硬性失败的情况是检查引用的源文件完全缺失(例如,android/ 下没有 <Pascal>Module.kt,或 ios/ 下没有 RCT<Pascal>Module.m)。这不是契约偏差,而是缺少构件。打印 ❌ Layer 0 (contract consistency): cannot proceed — <missing file> 并停止。
步骤 3 — Layer 1:原生源代码结构断言
此流程不运行 tsc——该控件以原生二进制形式交付,而非 TS 扩展。相反,它会 grep/解析原生模块源代码,并断言其结构符合封装运行时及插件的上传兼容性检查要求。这些是 /build-android-binary 和 /build-ios-binary 在编译前运行的相同一致性断言(ppmplugin-format §5、§5b)——在此处运行,可在耗时数分钟的构建前及时发现能在几秒内修复的错误。
对于 Android(android/.../<Pascal>Module.kt):
- 模块类 继承
ReactContextBaseJavaModule(宿主会将其作为 React Native 模块加载)。 override fun getName()返回 带规范前缀的'<Pascal>Module'(ppmplugin-format §3)——如果暂存了清单,其值 MUST 与清单中的receivers[].nativeModule相等。- 至少声明一个
@ReactMethod fun <m>(...)(没有@ReactMethod的模块无法分发任何内容)。 - 存在一个
ReactPackage(DEX 所需的createNativeModules注册项——其 FQN 会成为entrypoints.android.packageClass)。 - 不含
@ReactModule注解(这是 SDK 时代的注册路径;封装宿主通过ReactPackage注册,而非使用注解——存在该注解意味着泄漏了 SDK 代码,会被/audit-ppmplugin拒绝)。
对于 iOS(ios/RCT<Pascal>Module.h/.m):
- 头文件类 声明
<RCTBridgeModule>。 - 不含
RCT_EXPORT_MODULE(...)宏;.m声明+ (NSString *)moduleName,其返回字符串与 Android 的getName()及清单中的receivers[].nativeModule相等。Objective-C 类名与entrypoints.ios.moduleClass相等。 - 至少声明一个
RCT_EXPORT_METHOD(<m>:...),且方法名是清单中receivers[].methods[]的子集。
加载与初始化就绪状态(启动时崩溃/无法加载断言)— 强制门槛
报告最多的现场故障是**“应用无法安装或启动时崩溃”——根本原因几乎总是插件无法加载**,或模块在急切构造时抛出异常,且发生在任何 UI 出现之前。这些问题可从源代码中判断,因此应在此处断言(Layer 1 在失败时停止),而不是留给仅发出警告的 Layer 0 扫描或构建后审计。依据 ppmplugin-format §5(Android DEX 加载与急切构造)和 §5b(iOS dlopen 与 [cls new])。
Android(android/.../<Pascal>Package.kt 加模块):
ReactPackage类具有公开的无参构造函数——不能是带参数列表的class <Pascal>Package(...)。封装运行时通过getDeclaredConstructor().newInstance()实例化它;带参构造函数会抛出NoSuchMethodException,导致插件静默加载失败(Loaded 0 plugin package(s))。(模块自身可以接收ReactApplicationContext;但包必须无参。)- 构造闭包不会抛出异常——检查模块构造函数、
init{}代码块和属性初始化器(以及它们调用的一级私有函数),查找明确的崩溃触发条件:可能运行在没有 Looper 的线程上的裸Handler()/register*Callback(…, null),或未捕获的 I/O/硬件获取异常。此处抛出异常会导致宿主在启动时崩溃。(这是 Layer 0 #12 启发式检查的强化硬门槛版本——Layer 0 广泛发出警告;Layer 1 则会阻止明确的触发条件。)
iOS(ios/RCT<Pascal>Module.{h,m}):
- 模块类通过
[cls new]实例化——不能有接收参数的自定义初始化器(播放器会执行NSClassFromString(moduleClass)→[cls new];若只有带参初始化器,模块会在加载时被跳过)。 + (BOOL)requiresMainQueueSetup返回NO(如果已声明)。返回YES会强制在启动时进行主线程设置;若同时存在耗时或会抛出异常的init,则会导致启动卡顿或崩溃。init/+load不执行任何可能抛出异常或耗时的工作——遵循与 Android 相同的急切构造规则;将硬件/监听器的初始化延迟到首次方法调用时。
通过
输出 🟢 ✓ Layer 1 (native-source structure): pass — <ISO time>。继续执行 Layer 2。
失败
上方断言失败。列出最先发现的 3 个不匹配项,每项格式为 <file>:<line> — <what's wrong>。常见类别及修复方法:
- 捕获相关的 grep 命中项/未命中项。
- 打印最先发现的 3 个(最相关的)不匹配项,不要打印所有 grep 行。
- 为每个类别建议修复方法:
- Android
getName()返回'<X>',但 iOS+moduleName/manifestnativeModule返回'<Y>'→ 运行时符号不一致。将三者统一为带规范前缀的'<Pascal>Module'(ppmplugin-format §3)。如果派生名称命中保留前缀/拒绝名单,按ppmplugin-format §4重命名。如果 Obj-C 类名与entrypoints.ios.moduleClass不同,请分别统一类名或 manifest 入口点。 - module 未继承
ReactContextBaseJavaModule(Android)/未声明<RCTBridgeModule>(iOS)→ 无法注册为原生模块。修复类声明。 - 未找到
@ReactMethod/RCT_EXPORT_METHOD→ 模块没有提供宿主可调用的内容。根据 ARCHITECTURE §4 添加操作方法。 - Android module 上存在
@ReactModule注解 → 遗留的 SDK 时代代码;将其移除(封装宿主通过ReactPackage注册)。这是/audit-ppmplugin阶段的硬性 CRITICAL。 ReactPackage有带参数的构造函数(class <Pascal>Package(...))→ 插件在设备上加载的包数量为 0。为其提供公共无参构造函数(ppmplugin-format §5)。- 构造闭包会抛出异常(不带 Looper 的
Handler()、register*(…, null)、构造函数/init{}中未捕获的 I/O 异常)→ 宿主会在启动时崩溃。延迟到首次调用时再进行惰性初始化,传入Handler(Looper.getMainLooper()),并用try/catch包裹无法避免的初始化。 - iOS module 没有无参
init/requiresMainQueueSetup返回YES/init执行耗时或会抛出异常的操作 → 模块会在dlopen加载时被跳过,或导致启动停滞。通过[cls new]实例化,让+requiresMainQueueSetup返回NO,并确保init开销小(ppmplugin-format §5b)。
- Android
- 更新
.extension-state.md:Native-source structure (Layer 1): fail — <timestamp>;Status: blocked;Blocked reason: <first mismatch>。 - 停止。不要运行后续层。
第 4 步 — 第 2 层:Manifest 验证
仅当未找到 manifest 时自动跳过(根据第 2 步的检测结果,
$MANIFEST为空——./manifest.json和暂存副本均不存在)。打印⊝ Layer 2 (manifest validation): SKIPPED — no manifest on disk (hand-authored module). Run /generate-ppmplugin-manifest first.将状态标记为n/a。继续第 3 层。(使用脚手架时,native-gen 会生成./manifest.json,因此通常会在此处运行这一层。)
当磁盘上存在 manifest($MANIFEST——已提交的 ./manifest.json 或暂存副本)时,在本地对其重新运行插件维护的上传兼容性检查——与 /generate-ppmplugin-manifest 运行的检查相同(ppmplugin-format §4)。这项开销很低的预检可在构建前发现常见上传失败原因,例如 name 或 nativeModule 格式错误。这一层以 /generate-ppmplugin-manifest 为规则集的准则——不会重新编写 manifest,只验证磁盘上的版本,并指向该技能进行修复。
通过
打印 ✓ Layer 2 (manifest validation): pass — <ISO time>。继续第 3 层。
失败
违反了 ppmplugin-format §4 中的一条规则。常见类别及修复方法:
| 类别 | 操作 |
|---|---|
name 正则不匹配 / 不是类名的 kebab-case | name 必须是 CLASS 名称的 kebab-case 形式(ppmplugin-format §3)。重新运行 /generate-ppmplugin-manifest 以重新推导。 |
| 规范前缀违规 | 每个 receivers[].nativeModule 都必须以 name 的规范前缀开头(按 -/_ 拆分,分别转为 PascalCase 后拼接)。重命名模块的 getName() 或修正 name。 |
| 保留前缀 / 禁用列表 | nativeModule 使用了保留前缀或 Microsoft 所有的裸名称(ppmplugin-format §4)。改用非保留形式(添加 Module 后缀或供应商前缀)。 |
methods[] 与源代码不匹配 | methods[] 中的方法在模块中没有对应的 @ReactMethod / RCT_EXPORT_METHOD,反之亦然。重新运行 /generate-ppmplugin-manifest,从源代码重新推导 methods。 |
更新状态:Manifest validation (Layer 2): fail — <timestamp>;Status: blocked。停止。
范围说明: 此层仅验证
manifest.json。原生源代码的结构由 Layer 1 验证;它声明的二进制文件是否实际存在,由/assemble-ppmplugin的协调门控检查;构建出的.ppmplugin是否能在设备上加载,由/audit-ppmplugin检查。
步骤 5 — Layer 3:原生源代码契约断言
此轨道无需运行 tsc --noEmit 测试样例——这里没有 TS src/,也没有可供类型测试样例固定其结构的 src/types.ts。相反,此层会通过 grep 交叉核对两个原生解析器(iOS .m、Android .kt)与 PCF 之间的请求 / 响应 / 错误代码契约,并根据 ARCHITECTURE §4 推导预期字段集(如果没有 PRD,则将原生源代码视为事实来源)。这是 Layer 0 检查 4–6 的深入版——Layer 0 会在全面检查中以仅警告的方式提示这些问题;Layer 3 则会据此进行门控。
对于每个操作:
- 请求字段 — iOS 解析器中的 NSDictionary 键读取、Android 解析器中的 ReadableMap 键读取,以及 PCF 中的
request.<field>构建,都必须引用相同的字段名称(ARCHITECTURE §4.1)。某个平台读取了某个字段,另一个平台却没有 = 平台特有的 INVALID_INPUT。 - 响应字段 — iOS
successJsonWith:构建、AndroidsuccessJson构建中的键,以及 PCF 的response.result.<field>读取,必须一致(ARCHITECTURE §4.2)。 - 错误代码 — ARCHITECTURE §5 中的每个代码都必须至少由一个原生位置发出(iOS
errorJsonWithCode:message:、AndroiderrorJson(code, message)),并由 PCFsetError处理(case 或 default)。 - 错误消息传递 — 原生错误辅助函数必须包含
message参数;PCF 的setError(code, message)必须有两个参数并赋值给this.errorMessage;ControlManifest 必须声明三个标准诊断输出(Status、ErrorCode、ErrorMessage),且getOutputs()必须返回这些输出。这样,Power Fx 中的字段故障才能在没有原生控制台的情况下进行调试。
通过
打印 ✓ Layer 3 (native-source contract): pass — <ISO time>。继续执行 Layer 4(如果正在运行)。
失败
此处最常见的失败是某个平台读取或发出了某个字段,而另一个平台没有;或者 §5 中声明的错误代码没有任何原生位置发出。
| 错误类别 | 可能的修复方法 |
|---|---|
| 某个解析器中存在字段,但另一个解析器中缺失 | iOS 和 Android 解析器不一致。添加缺失的键读取(或移除多余的读取),使两者根据 ARCHITECTURE §4.1 引用相同的字段名称。 |
| 原生代码中的响应键未被 PCF 读取(或反之) | 原生代码发出了 PCF 从未读取的键,或者 PCF 读取了 response.result.<x>,但没有任何原生位置发出该键。对齐原生构建位置与 PCF 读取。 |
| §5 中的错误代码没有原生发出位置 | 声明的代码无法触发。可以在相关的原生错误路径中发出该代码,或者将其从 §5 中删除(PRD 不一致)。 |
更新状态:Native-source contract (Layer 3): fail — <timestamp>;Status: blocked。停止。不要运行 Layer 4。
第 5.5 步 — 第 3.5 层:模拟上下文运行时契约
此流程中没有对应项 — 始终为
n/a。 模拟上下文运行时层会实例化 TS 扩展类(new <Pascal>Extension(ctx)),并针对最小化/完整的主机上下文形态调用handleMessageAsync。.ppmplugin不包含 TS 扩展类、handleMessageAsync或主机INativeExtensionContext— 分发会通过 wrap 桥接直接调用NativeModules.<nativeModule>.<method>(ppmplugin-format §2、§6)。没有任何内容需要在设备外实例化。
对于纯原生捆绑包,“它是否真的能运行?”这一等效保障存在于两个地方,这两项都无法通过此技能在工具链之外完成:
- 原生代码能否编译为可加载的二进制文件 →
/build-android-binary///build-ios-binary(真实的 Gradle / xcodebuild)。 - 构建后的
.ppmplugin能否在 wrap 运行时加载并分发 →/audit-ppmplugin(字节扫描 + 结构门禁)以及第 5 层设备方案。
输出 ⊝ 第 3.5 层(模拟上下文运行时):n/a — 纯原生流程没有可实例化的 TS 扩展类(参见 /audit-ppmplugin + 第 5 层)。,然后继续第 4 层。
第 6 步 — 第 4 层:PCF 编译
如果在
pcf/下未找到ControlManifest.Input.xml,则自动跳过(依据第 2 步中基于find的检测)。输出⊝ 第 4 层(PCF 编译):已跳过 — PCF 尚未搭建脚手架。请先运行 /generate-pcf-companion。将状态标记为n/a。继续第 5 层。
此层会编译 Companion PCF — 清单 XML + index.ts + 工程师所做的任何自定义修改。可捕获:
index.ts中的 TS 错误(搭建脚手架后手动编辑引入)ControlManifest.Input.xml与index.ts之间的属性名称不匹配(例如,清单声明了PenColor,但代码引用了p.penColor)- 缺少输出属性声明(例如,编辑了 §8.3,但未重新生成 PCF)
pcf-scripts依赖损坏
PCF 工具链使用 npm,而不是 pnpm — 这是 PCF 生态系统的惯例。不要统一。
运行
在仓库根目录执行:
macOS / Linux / Windows(相同命令):
cd "$PCF_PROJECT_ROOT" # from the find-based detection in Step 2
[ -d node_modules ] || npm install --no-audit --no-fund # first time only; ~30s
npm run build --silent
注意:
npm install仅在首次运行时执行(或node_modules/被清除时)。后续运行只需几秒,因为热缓存下pcf-scripts build很快。--silent会精简输出;错误仍会显示。npm run build会运行pcf-scripts build,它会:- 根据清单 XML 重新生成
pcf/<Pascal>PCF/<Pascal>PCF/generated/ManifestTypes.d.ts。 - 根据重新生成的
ManifestTypes.d.ts对index.ts进行类型检查。 - 将输出捆绑到
pcf/<Pascal>PCF/out/。
- 根据清单 XML 重新生成
通过
npm run build 以 0 退出并生成 out/ 构建产物。输出 ✓ 第 4 层(PCF 编译):通过 — <ISO time>。
失败
npm run build 以非零值退出。常见错误类别:
| 错误类别 | 可能的修复方法 |
|---|---|
Property '<X>' does not exist on type 'IInputs' | 清单声明了一个名称,而 index.ts 引用了另一个名称。并排打开 ControlManifest.Input.xml 和 index.ts;统一属性名称(区分大小写)。 |
Property '<X>' is missing in type(出现在 getOutputs() 返回值上) | 已在清单中添加输出,但未在 getOutputs() 中返回,反之亦然。将缺少的字段添加到 getOutputs(),或移除多余的清单条目。 |
Cannot find module 'pcf-scripts' | 首次运行尚未完成 npm install。该技能本应已运行此命令;如有需要,请手动重新运行 npm install。 |
XML parsing failed at line <n> | 清单 XML 格式错误。最常见的情况是标签未闭合,或在不允许默认值的属性上设置了 default-value 属性(例如,usage="output" 属性不能设置默认值)。 |
index.ts 中的标准 tsc 错误 | 工程师引入的回归问题。根据行号和列号检查并修复源代码。 |
更新状态:PCF build (Layer 4): fail — <timestamp>;Status: blocked。停止。不要运行第 5 层。
第 6.5 步 — 第 4.5 层:Android lint
在本地捕获“Android 构建因 lint 拒绝此模块”这类 bug。 在 pen-input 上的实际代价是:集成构建运行时才发现 8 条 Android lint 警告,不得不再来回修补一轮。每条警告都可以在此处提前发现。
Android Studio 的 lint 规则比快速 grep 更严格(会检查调用模式、资源引用、已弃用的 API、无障碍功能)。/build-android-binary 会使用固定版本的 AGP 编译暂存副本模块;先在此处运行 ./gradlew lint,就能在该构建之前发现相同的警告。
运行方式
# Detect whether gradle wrapper is present
if [ -f android/gradlew ]; then
cd android && ./gradlew lint --warning-mode=summary
fi
如果不存在 android/gradlew(扩展在此层成为标准之前已搭建)→ 跳过并提示 Layer 4.5 (Android lint): skipped (no gradle wrapper at android/gradlew — hand-add the gradle wrapper to android/).。不要让运行失败;只需注明这一缺口。
如果存在 gradle wrapper,但 ./gradlew lint 以非零状态退出 → 解析 android/build/reports/lint-results-debug.html 中的报告(或 stderr),并将警告作为编号列表展示。
通过
输出 ✓ Layer 4.5 (Android lint): pass — <ISO time>。继续到第 5 层。
警告(继续到第 5 层,不让运行失败)
⚠️ Layer 4.5 (Android lint): <N> warning(s) — the Android build may reject these
1. NewApi at <file>:<line>
Call requires API level X (current min is Y). Either bump minSdk, gate the call,
or suppress with @TargetApi(X) annotation.
2. UnusedResources at res/drawable/ic_unused.png
Drawable not referenced; safe to remove.
... etc.
然后执行 AskUserQuestion:
Question: "Android lint found <N> warning(s). What now?"
Header: "Lint warnings"
Options:
1. "Continue to Layer 5" description: "Warnings noted in .extension-state.md. Fix later; not all warnings block /build-android-binary."
2. "Stop, I'll fix first" description: "Skill exits. Address the warnings (typically straightforward), then re-run /test-native-extension."
3. "Show me the full lint report" description: "Print the full HTML report path + key offending lines, then re-ask."
失败
./gradlew lint 出错(不是警告,而是真正的失败:gradle 守护进程崩溃、依赖解析失败等)。展示错误末尾内容,并以 Layer 4.5: fail — <one-line cause> 停止。用户修复 gradle 设置后再重试。
跳过
如果项目完全没有 android/ 文件夹(仅 TS 或仅 iOS 的扩展——较少见)→ Layer 4.5: skipped (no android/ in repo). 继续到第 5 层。
步骤 7 — 第 5 层:手动设备/模拟器指导
这一层是交互式的,不属于自动化范围——需要已构建并通过审计的 .ppmplugin、加载它的 wrap 主机、设备或模拟器,以及已部署到测试环境的 Companion PCF。技能会打印一份可复制粘贴的操作指南;工程师执行操作;技能记录结果。
为什么第 0–4 层全部通过后,第 5 层仍然不可或缺。第 0–4 层验证的是结构——符号匹配、密钥一致、清单对齐、PCF 可编译。**第 5 层才首次让实际字节通过线路传输。**所有结构层都无法发现的线路格式和运行时错误——例如直接调用
cordova.exec而不是sendAsync(→ 不会进行任何分发,在 Android 上静默无反应)、主机对响应重新字符串化、PCF 必须解包的 wrap{message}响应容器、启动时抛出异常的原生模块构造函数(→ 在任何 UI 出现前启动即崩溃)、运行时权限被拒——只有在这里才会暴露。第 0–4 层全绿意味着“结构正确”,而不是“能在设备上分发”。不要把自动化测试全绿视为端到端验证;首次启动和首次点击才是真正的测试。(第 0 层会在设备测试前通过 grep 检查sendAsync传输、构造函数崩溃模式和响应解包(检查项 1b/12/13);下面的第 5 层崩溃扫描才是最终检查。)
打印:
Layer 5 — Manual end-to-end validation (interactive)
─────────────────────────────────────────────────────
This validates the full path: Canvas formula → PCF → SendMessagePlugin bridge → NativeModules.<nativeModule>.<method> → response back to Canvas.
PREREQUISITES (one-time per machine):
• A built + audited bundle: run /generate-ppmplugin (or the stage skills) so ppmplugin/<name>.ppmplugin exists and passed /audit-ppmplugin
• A wrap host configured to side-load the .ppmplugin (the wrap shell from the wrap pipeline — its load/upload flow is owned there)
• Active pac auth profile against your test environment (`pac auth list` — star should be on the test env)
• For iOS device: a provisioning profile that allows running the wrap app on your device
• For Android device: USB debugging enabled + `adb devices` shows your device
BUILD & AUDIT THE BUNDLE (each iteration of the control):
# 1. From the control repo, produce a verified bundle:
cd <this repo>
/generate-ppmplugin
# → drives manifest → build-android/ios → assemble → audit; lands ppmplugin/<name>.ppmplugin
# 2. Confirm it's READY TO UPLOAD:
/audit-ppmplugin
# → CRITICAL-free verdict before you side-load it
LOAD INTO THE WRAP HOST:
# Side-load ppmplugin/<name>.ppmplugin into the wrap host per the wrap pipeline' load flow.
# The host runtime registers the DEX's ReactPackage / the iOS framework's moduleClass +moduleName;
# the manifest's receivers[] become the routing keys the SendMessagePlugin bridge dispatches to.
DEPLOY THE PCF (if not already done):
# Easiest: use the dedicated skill — it handles prefix selection + env confirmation:
/publish-pcf-companion
# Manual equivalent (substitute a 2–8 char prefix):
cd pcf/<Pascal>PCF
pac pcf push --publisher-prefix pamext
# The control appears in Canvas Studio under Custom controls > PowerApps namespace.
RUN ON DEVICE:
# iOS: build & run the wrap app on simulator or device (Xcode).
# Android: install the wrap app on connected device / running emulator.
RUNTIME CRASH-SCAN (Android — the DEFINITIVE catch for the launch-crash + response-shape classes
that every static layer is blind to; run right after launching the wrap app):
# A) CONSTRUCTION CRASH — a native-module constructor / init{} that throws at bridge startup
# takes down the host BEFORE any UI (e.g. registerTorchCallback(cb, null) on a Looper-less
# thread → "Can't create handler…"). Launch the app, then scan logcat for the signatures:
adb logcat -c
adb shell monkey -p <wrap-package> -c android.intent.category.LAUNCHER 1 >/dev/null
sleep 8
if adb logcat -d | grep -iE "Looper\.prepare|Can't create handler|Loaded 0 plugin package|UnsatisfiedLinkError|FATAL EXCEPTION"; then
echo "✗ LAUNCH CRASH / plugin-load failure — see the matched line (fix: defer ctor side-effects to lazy init, Handler(Looper.getMainLooper()), try/catch)"
else
echo "✓ no launch crash / plugin-load failure in the first 8s"
fi
# B) RESPONSE ROUND-TRIP — a PCF that only deepParses misses the wrap {isUpdate, message:"<json>"}
# container and fails EVERY call with UNEXPECTED_PAYLOAD though native succeeded. On the canvas
# screen bind a label to Self.<name>Json, tap the control once, and read it:
# ✓ shows the module's own {"status":"ok",…} → PCF unwraps correctly
# ✗ shows {"isUpdate":…,"message":"{\"status\"…"} → PCF isn't unwrapping the
# container (fix: extractResponse, not a bare deepParse — /generate-pcf-companion + §2)
# ✗ Status=error, ErrorCode=UNEXPECTED_PAYLOAD → same root cause
INSTALL-FAILURE SCAN (run if the wrap app won't install at all — before the launch scan):
# Android — a failed `adb install` prints the reason; the common ones map to a fix:
# INSTALL_FAILED_INSUFFICIENT_STORAGE → free space / wipe the emulator
# INSTALL_FAILED_UPDATE_INCOMPATIBLE / signatures do not match → uninstall the old app first:
# adb uninstall <wrap-package>
# INSTALL_FAILED_NO_MATCHING_ABIS → the .so / ABI in android/lib/<abi> doesn't match the device
# INSTALL_FAILED_OLDER_SDK → device OS below the wrap app minSdk
# iOS — install/launch failing before any JS runs is usually signing, not your module:
# "Unable to install" / codesign / provisioning → the wrap app's profile doesn't allow this device
# dyld: Library not loaded / @rpath …<Name>.framework → the framework wasn't embedded by the wrap
# pipeline (a packaging issue, not a source bug) — re-check /assemble-ppmplugin + /audit-ppmplugin.
RUNTIME CRASH-SCAN (iOS — the analogue of the Android scan; run right after launching the wrap app on
a simulator/device. A throwing/heavy init or a module that won't load surfaces in the device log):
# Stream the wrap app's log and scan for the iOS launch/init + load-failure signatures:
xcrun simctl spawn booted log stream --level=default --predicate 'processImagePath CONTAINS "<wrap-app>"' &
# (device: use Console.app filtered to the app, or `idevicesyslog`.) Then launch the app and watch for:
# ✗ "Native module '<X>' not loaded" / NSClassFromString → nil → +moduleName / dead-code-stripping / used RCT_EXPORT_MODULE (§5b)
# ✗ dyld: Symbol not found / @rpath …React → React linked strongly, or RN pin ≠ host RN
# ✗ *** Terminating app due to uncaught exception … in -[<Module> init] → throwing init (defer to lazy first call)
# ✓ no such lines in the first ~8s after launch → no iOS launch/init crash
WHAT TO VERIFY (per ARCHITECTURE §8 Edge cases — copy into a checklist):
[ ] Happy path: <one-line happy-path scenario>
Expected: <Pascal>PCF1.<OutputName> = '<value>' per ARCHITECTURE §8
[ ] Error case <CODE_1>: <how to trigger>
Expected: <Pascal>PCF1.<OutputName> = 'error: <CODE_1>'
[ ] Error case <CODE_2>: <how to trigger>
Expected: <Pascal>PCF1.<OutputName> = 'error: <CODE_2>'
... (one row per error code in ARCHITECTURE §5)
TROUBLESHOOTING:
• Bridge resolves "NOT_IN_PAM" → opening in Studio preview (no bridge). Open in the wrap app on a device.
• "native module '<nativeModule>' not loaded" → the .ppmplugin didn't register. Re-run /audit-ppmplugin (catches the DEX/framework structure + SDK-leakage causes on disk) and re-side-load.
• Bridge times out → check Logs via Xcode Console (iOS) or `adb logcat` (Android) filtered to `<nativeModule>` or `SendMessagePlugin`.
根据文档调整每个 <...>。从 ARCHITECTURE §6.2(桥接接线触发条件)和 PRD §1(摘要)提取顺利路径场景。从 ARCHITECTURE §8 提取每条错误行。
打印后继续。不要等待用户执行操作指南并反馈——手动设备测试可能需要 5–10 分钟;让技能阻塞等待会使迭代循环变得不便。该操作指南是后续步骤指引,不是设有门槛的层。
技能在第 5 层的工作到此为止。用户自行安排时间执行操作指南。之后如需记录结果,用户可以:
- 直接编辑
.extension-state.md——在“Validation history”下,将Manual / device (Layer 5):行更新为pass | partial | fail | manual+ ISO 时间戳 +(可选)失败行说明。 - 重新运行
/test-native-extension——第 1–4 层耗时很短;第 5 层操作指南会重新打印,用户再次执行后可手动更新状态。
技能的职责是确保操作指南正确,并根据当前 PRD 进行调整。执行操作和记录结果由用户负责。
步骤 7.5 — 第 6 层(可选):深入审计 .ppmplugin 的指引
**仅提供指引——转到
/audit-ppmplugin。此处不运行。**仅当磁盘上已有构建好的包时才相关。
如果存在已构建的 ppmplugin/*.ppmplugin(本技能的结构层针对源代码运行,不会解压包),请指向专用的深度验证关卡,而不是重新实现它:
BUILT_BUNDLE=$(ls ppmplugin/*.ppmplugin 2>/dev/null | head -1)
如果设置了 $BUILT_BUNDLE,打印:
ⓘ Layer 6 (deep bundle audit): a built bundle exists (<path>).
Run /audit-ppmplugin for the byte-level verification this skill can't do on source:
zip structure, manifest↔bundle consistency, DEX SDK-leakage byte-scan, iOS framework
structure — the gate that proves it will actually load + dispatch on the wrap runtime.
如果不存在已构建的包,则静默省略此层(暂时无内容可审计——构建技能尚未运行)。
步骤 7.6 — 第 7 层(可选):深入推理审查运行时安全
可选、仅供参考、覆盖整个代码库。是第 0 层快速确定性 grep 检查的推理对应项。 第 0 层(检查项 12–17)和
/audit-ppmplugin的 F 类别可以发现运行时安全问题中可确定检测的部分——没有 Looper 的构造函数Handler、未解包的{message}响应、没有任何 settle 位置的 Promise 参数方法、已声明但未检查的危险权限、未加防护的currentActivity。这些检查速度快且误报少,正是因为范围狭窄。本层会进行广泛的推理检查,捕捉 grep 无法发现的问题:资源仅在部分路径上释放、Promise 在错误分支中被重复 settle、经过多层调用后从非主线程呈现 UI、在辅助方法中获取却从未清理的管理器。
何时提供。 自动化层全部通过后,如果控件包含原生模块(存在 ios/ 或 android/),则通过步骤 8.1 的后续步骤门控将其作为一个选项提供——绝不默认运行。此检查会推理整个代码树(速度较慢),并且仅供参考:它会返回 DONE_WITH_CONCERNS,但不会将 Overall 改为 blocked。广泛的推理检查不得因误报而阻止正常运行的控件。
运行内容。 阅读 shared/self-critique-protocol.md,并以整体审查模式运行(参见该文件的运行模式)——也就是说,枚举控件中的每个源文件(ios/、android/、pcf/、manifest.json、AndroidManifest.xml / Info.plist、build.gradle / podspec),而不只是本次运行修改的文件——重点关注门控 9(运行时安全与资源生命周期)。门控 1–8 也适用,但门控 9 才是本层存在的原因;它是对第 5 层设备端崩溃扫描的独立于设备的补充。
输出。 按模块列出问题表(Item | Question | Finding | Severity | Fix)。打印 ⓘ Layer 7 (deep runtime-safety review): <N> concern(s) — advisory, not blocking 并列出问题数量。在 .extension-state.md 的 Validation history 下记录数量,格式为 Deep review (Layer 7): <N> concerns — <timestamp>。如果用户希望修复任何问题,则转到 /debug-extension。
步骤 8 — 最终报告和状态更新
生成摘要表:
Test summary
────────────
Layer 0 (contract check): <pass | warn-N | skip> — <time>
Layer 1 (native structure): <pass | fail | skip> — <time>
Layer 2 (manifest validation): <pass | fail | n/a — no manifest> — <time>
Layer 3 (native contract): <pass | fail | skip> — <time>
Layer 4 (PCF compile): <pass | fail | n/a — PCF not scaffolded> — <time>
Layer 5 (manual E2E): recipe printed (run on device when ready)
Layer 7 (deep runtime review): <N concerns | not run — opt-in>
Overall (automated): <pass | blocked>
Note: native iOS / Android compile into a loadable DEX / framework is NOT validated by this skill — run /build-android-binary // /build-ios-binary.
此技能的 Overall 仅反映自动化层(1–4)。 第 5 层仅提供操作指南——其执行和结果记录由用户负责。用户在设备上运行操作指南后,需手动更新 .extension-state.md。
更新 ./.extension-state.md:
Validation history块:为每个自动化层(1–4)各写一行,包含结果和 ISO 时间戳。第 5 层对应的行保留为manual / device (Layer 5): recipe printed at <timestamp>——用户在设备上运行后手动更新。Phase:如果已运行的所有自动化层(1–4)均通过,则设置Last completed: tested和Next: /generate-ppmplugin or /audit-ppmplugin or /publish-pcf-companion(根据用户下一步意图选择适用项)。否则保留Status: blocked,并注明首个失败层的原因。
8.1 下一步选择
根据 shared/shared-instructions.md §9.1 和反馈记忆 [[feedback-skills-execute-not-describe]]:当存在实际的下一步选项时,通过 AskUserQuestion 提供结合上下文的选项,而不是纯文本项目符号列表,也不是忽略当前可行操作的固定列表。
失败时(任一自动化层失败):
不要使用 AskUserQuestion——此时只有一个合理的下一步(修复并重新运行)。请输出:
Fix the <failing layer> error above (most relevant log line surfaced in Step 8 summary),
then re-run /test-native-extension. Re-runs are idempotent — pass-through to the failing layer.
成功时(所有自动化层均通过):
首先,扫描代码库和状态文件,确定哪些选项适用。这就是“找出在各种情形下之后可能触发哪些技能”的部分——不要显示当前无法成功执行的选项。
| 检测条件 | 意味着 |
|---|---|
存在 ./manifest.json,但尚未构建任何内容(ppmplugin/ 为空) | /generate-ppmplugin 是规范的下一步——它会验证并暂存 ./manifest.json,然后驱动 build → assemble → audit,最终生成经过验证的软件包。 |
完全不存在 ./manifest.json(手动编写的模块) | /generate-ppmplugin-manifest(或将其作为一个阶段运行的 /generate-ppmplugin)会先根据源代码编写清单。 |
存在已构建的 ppmplugin/*.ppmplugin | /audit-ppmplugin 是自然的后续步骤——此技能的仅源代码层无法完成的深度验证关卡。 |
存在 pcf/ 文件夹,且其中有 ControlManifest.Input.xml | PCF 已搭建;可使用 /publish-pcf-companion。 |
不存在 pcf/ | /generate-pcf-companion 适用;/publish-pcf-companion 目前尚不适用。 |
.extension-state.md 中有 ## PCF deployments 条目 | PCF 至少部署过一次;再次运行 /publish-pcf-companion 只会更新版本并推送。 |
存在 ios/ 或 android/,且本次会话尚未运行 Layer 7 | 提供深度运行时安全审查(Layer 7)——可选的全代码库推理检查(Gate 9),用于捕获 Layer 0 grep 无法发现的多路径资源/Promise/线程/权限问题。仅作建议,绝不设为默认选项。 |
然后调用 AskUserQuestion,提供最相关的 3–4 个选项,以及一个“暂不操作”的退出选项。排序:最规范的下一步优先;其他选项按优先级排列;退出选项放在最后(遵循 §9.1 中的多选项规则)。
选项组合示例(实际选项必须根据每次运行时的上述检测结果确定):
| 代码库状态 | 问题选项 |
|---|---|
存在 ./manifest.json,尚未构建任何内容 | /generate-ppmplugin(推荐)//generate-pcf-companion//debug-extension/暂不操作 |
不存在 ./manifest.json(手动编写的模块) | /generate-ppmplugin(推荐)//generate-ppmplugin-manifest//debug-extension/暂不操作 |
| 存在已构建的软件包,尚未审计 | /audit-ppmplugin(推荐)//publish-pcf-companion//debug-extension/暂不操作 |
| 软件包已审计,PCF 已部署 | /publish-pcf-companion(推荐)//audit-ppmplugin(重新验证)//debug-extension/暂不操作 |
/debug-extension 后重新运行(增量) | /generate-ppmplugin(推荐)//debug-extension/暂不操作(在用户明确要求发布之前,不提供发布选项) |
选项说明应指出当前状态为何适合选择该选项——例如 /generate-ppmplugin:「驱动 manifest → build-android/ios → assemble → audit,并在磁盘上生成经过验证的 .ppmplugin——这正是本次结构预检为其铺平道路的二进制文件。」这样用户就能理解提供各选项的原因。
问题模板:
Question: "What would you like to do next?"
Header: "Next step"
Options:
1. "Run /<recommended>" description: <what it does in this state>
2. "Run /<alternate-1>" description: <…>
3. "Run /<alternate-2>" description: <…>
4. "Stay — I'll review the test report"
description: "Skill exits. Inspect Layers 1–5 output above; decide what to run next yourself."
只要 Layers 1–4 通过,就始终将 /generate-ppmplugin(不存在已验证的软件包时)或 /audit-ppmplugin(存在已验证的软件包时)作为选项之一——无论代码库状态如何,构建/验证 .ppmplugin 都是结构预检顺利通过后的自然下一步。
返回状态协议
最终消息的第一行必须是以下内容之一:
| 代码 | 含义 |
|---|---|
DONE | 所有适用的自动化层(1–4)均已通过。状态已更新为 Phase=tested。(Layer 5 设备操作步骤已打印;用户会单独运行。) |
DONE_WITH_CONCERNS: <list> | 所有自动化层均已通过,但存在注意事项——通常是延后了 Layer 4(尚未搭建 PCF),因此工程师应在认定测试完成前运行 /generate-pcf-companion。 |
NEEDS_CONTEXT: <missing> | 因缺少必需输入而无法运行(例如,ARCHITECTURE §8 为空,无法构建 Layer 5 操作步骤清单——但其余流程仍已完成;这是软性 NEEDS_CONTEXT)。 |
BLOCKED: <reason> | 某个自动化层失败且无法自动修复。状态标记为 blocked,并附上首个失败层的错误消息。 |
第一行之后空一行,然后给出便于阅读的摘要。
硬性规则
- 自动化层一旦发生故障,立即停止。 如果 Layer 1 失败,就不要运行 Layer 2,以此类推。避免浪费计算资源并造成报告混乱。
- Layer 5 仅打印操作步骤。 不要询问用户“通过了吗?”——不要让技能受耗时 5–10 分钟的手动设备测试所阻碍。打印操作步骤,完成 Layers 1–4 的结果,然后退出。用户会在方便时自行运行操作步骤并手动更新状态。
- 此处不要运行原生编译。 编译成可加载 DEX/框架的原生构建属于
/build-android-binary///build-ios-binary——它们会根据固定的 AGP/xcodebuild 设置(ppmplugin-format §5、§5b),针对固定版本的 RN 构建暂存副本模块。此技能中直接运行pod lib lint/./gradlew assembleDebug会造成虚假的信心(它会从公共 CDN/maven 解析依赖,而不是使用 wrap 主机固定版本的 RN)。如果此技能的旧版本生成了.test/ios/或.test/android/脚手架目录,应将其删除——它们不属于当前流程。 - 不要使用
--force标志让某层“通过”。 修复源代码——不要禁用严格模式、抑制警告或跳过依赖解析。 - 不要在层执行过程中写入 .extension-state.md。 在结束时一次性更新所有层的结果;或者每次失败时递增更新并标记
Status: blocked——但如果中途停止运行,绝不能让文件处于半更新状态(Crashed-Claude 问题)。 - 不要运行 npm publish / pnpm publish / pac pcf push。 这些属于发布技能。