working-tree-tapp-cli-npx:把 Tapp 校验器变成可分发的契约工具
Myriad · HEAD 61ea971 · preview 工作树 · 2026-07-20 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开。代码片段均来自本次工作树中亲自读取的文件;青色斜体注释为解读所加,灰色斜体保留源码注释。每条旅程末尾给出排查路标。
1TL;DR
这组变更把 Tapp 校验规则从后端代码里的分散事实,变成一份可导出的契约:Rust 类型提供结构层,Rust 常量提供语义层,前端权限表提供 action/permission 目录,最后汇合到生成的 JSON。
CLI 通过这份 JSON 做 Manifest、资源、静态代码和权限检查;只有 starter 模板与 ZIP 机制保留手写。随后 CLI 被整理成 npm scoped package,发布前重新同步契约并跑测试,用户可用固定版本的 npx 在本地、Playground 和 CI 中复用检查器。
2变更地图(称重)
这是一份混合工作树,而非已提交 PR。已跟踪差异主要是后端规则抽取;未跟踪材料承载 CLI、生成产物、测试、样例工程和 handoff。
| 设计重心(要细读) | 可放心略过 |
|---|---|
contract_rules.rs、manifest.rs、exporter、sync-contract.mjs:定义契约来源与汇合点。 | Cargo.lock:依赖解析结果;样例 SVG、locale 和模板:用于验证布局。 |
project.mjs、CLI 入口与测试:决定用户运行时能得到什么。 | contract.json 大部分行:生成结果,不应逐字段手工维护。 |
3架构一图流
以前 · 规则只在后端路径
现在 · 源码契约进入多个消费者
明确的新边界是:生成器只在 Myriad 源码和发布流程中运行;用户侧 CLI 只读取随包分发的契约。
4数据与状态先行
| 形状 | 来源 | 下游用途 |
|---|---|---|
TappManifest JSON Schema | manifest.rs 的 JsonSchema 派生 | 字段、嵌套对象、required、Rust enum |
limits/rules/patterns | contract_rules.rs 常量 | 条件字段、数量、大小、路径、权限关系 |
permissions | 前端 permissionConfig.ts | action → permission、permission → level |
inspectProject() report | project.mjs | 诊断、缺少权限、调用位置、待打包文件 |
#[cfg_attr(feature = "tapp-contract-schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TappManifest {
pub id: String,
pub name: String,
pub version: String,
pub main: String,
#[serde(default)]
pub permissions: Vec<String>,
pub apis: Option<HashMap<String, TappApiDef>>,
}
类型负责“有哪些字段、字段长什么样”;像 Widget 权限、事件 topic 前缀、API 互斥字段这样的关系进入语义层。
5旅程 A:一条规则如何变成生成契约
A.1结构和语义被刻意拆开
manifest.rs 的结构与 enum 在 feature 打开时派生 Schema。分类别名不是 canonical enum,因此别名进入语义规则,CLI 再合并两组可接受值。
pub const MAX_TAPP_APIS: usize = 64;
pub const MAX_API_INJECT_ALIASES: usize = 32;
pub const API_BUILTINS: &[&str] = &["geo", "ai:chat", "ai:generate"];
pub const EVENT_TOPIC_PREFIXES: &[(&str, &[&str])] = &[
("publish", &["tapp.{id}."]),
("subscribe", &["tapp.", "system."]),
];
规则有稳定名字后,后端校验与 exporter 都能引用它;修改限制不再需要在 CLI 复制数字。
A.2Exporter 只负责拼出 JSON
exporter 用 schema_for! 获取结构层,再序列化 limits、rules、patterns。它不是第二个校验器,也不包含 npm 逻辑。
fn main() {
let schema = schemars::schema_for!(manifest::TappManifest);
let output: Value = json!({
"schema": schema,
"limits": { "tappApis": contract_rules::MAX_TAPP_APIS },
"rules": {
"apiBuiltins": contract_rules::API_BUILTINS,
"eventTopicPrefixes": list_map(contract_rules::EVENT_TOPIC_PREFIXES)
}
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
sync-contract.mjs 调 exporter,再从前端权限配置读取 permission levels 与 action map,写入 src/generated/contract.json。运行时 npm 包只带结果。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| CLI 仍接受旧限制 | contract_rules.rs → sync-contract.mjs → contract.json |
| 字段有了但 CLI 不认识 | manifest.rs 的 Schema 与 schemaFields() |
| 权限数量变化 | permissionConfig.ts 与 catalog drift test |
6旅程 B:一次 CLI check 如何走完工程
CLI 不启动后端,也不执行 Tapp 代码。它读取 Manifest、扫描资源与源码,把静态可证明的事实汇总成 report。
B.1Manifest 与资源先形成边界
project.mjs 从 generated contract 建立字段、枚举、limits 和 regex。未知字段产生诊断;声明资源转成 path/extension/kind 条目,再检查安全路径、扩展名、存在性和大小。
const contract = JSON.parse(
await readFile(new URL('./generated/contract.json', import.meta.url), 'utf8'),
)
const TOP_LEVEL_FIELDS = new Set(Object.keys(contract.schema.properties))
const WIDGET_SIZES = new Set(contract.rules.widgetSizes)
const MAX_ASSET_BYTES = contract.limits.assetBytes
const SAFE_COMPONENT = new RegExp(contract.patterns.safeComponent)
my-app 作为真实样例同时覆盖 page、widget、AI、events、Agent、Data Exchange、i18n 与 assets,检查结果为 0 条诊断、13 个 package files。
B.2静态源码把调用翻译成权限事实
扫描器识别 Tapp.storage.set() 这样的静态调用,从 action map 找到 permission,并保留文件与行列位置。动态属性访问无法证明,仍留给 warning 或运行时。
for (const match of source.matchAll(TAPP_ACTION_CALL)) {
const action = match[1] + '.' + match[2]
const permission = catalog.actions[action]
if (!permission) continue
usedActions.push({ action, permission, file, ...location })
addRequiredPermission(requiredPermissions, permission, reason, file)
}
report 因而能区分声明权限、实际所需权限和缺失权限;这仍不等于执行时授权,安装器会再次验证。
B.3pack 只消费检查结果
packProject() 先调用 inspectProject(),有 error 就停止;否则只把 report.packageFiles 写入 ZIP,并应用文件数、压缩大小和解压大小限制。
const report = await inspectProject(projectRoot)
if (report.diagnostics.some(({ severity }) => severity === 'error')) {
const error = new Error('Project validation failed')
error.report = report
throw error
}
for (const path of report.packageFiles) {
entries.push({ path, data: await readFile(join(report.root, path)) })
}
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| 字段或枚举被拒绝 | validateManifest()、validateFields() |
| 缺少权限 | inspectCode()、contract.permissions.actions |
| 包里没有声明资源 | resourceDeclarations()、validateResources() |
| 包大小超限 | packProject() 与 zip.mjs |
7旅程 C:同一个 CLI 如何变成 npx 包
npm 下载后,Node 只需找到 bin 与包内 generated contract;用户不再需要 Myriad 仓库路径、Rust 或 frontend 源码。
C.1bin 入口把 npm 包接到 CLI
入口文件只做 argv 转发与顶层错误转译;命令解析在 src/cli.mjs。package.json 暴露三个同实现名字,其中 tapp-cli 与 scoped package 短名一致。
"files": ["bin", "src", "README.md"],
"bin": {
"myriad-tapp": "bin/myriad-tapp.mjs",
"tapp": "bin/myriad-tapp.mjs",
"tapp-cli": "bin/myriad-tapp.mjs"
},
"publishConfig": { "access": "public" }
白名单让同步脚本与测试留在源码仓库;generated contract 进入 tarball,因为它是运行时依赖。
C.2发布前锁住契约与测试
prepublishOnly 在发布前重新生成契约并执行测试;它不会成为用户安装时的依赖。
"scripts": {
"sync-contract": "node ./scripts/sync-contract.mjs",
"test": "node --test",
"pack:check": "npm pack --dry-run",
"prepublishOnly": "npm run sync-contract && npm test"
}
npm publish --dry-run --access public,真实 npm 上传尚未发生;registry 中是否已有同名包没有在本报告中验证。排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| npx 找不到命令 | package.json 的 bin;本地等价验证是 npx ./tools/tapp-cli --version |
| 发布包找不到契约 | tarball 白名单是否包含 src/generated/contract.json |
| 发布前契约过期 | prepublishOnly、同步脚本与 drift test |
8计划与实现
| 原计划/讨论 | 工作树实际形态 |
|---|---|
| 可能先做 VS Code 插件,再考虑 CLI。 | 没有新增 VS Code Extension;先交付可被终端、Playground 和 CI 共用的 CLI。 |
| 除 starter 与 ZIP 外,其余字段、枚举、限制、权限和语义规则都生成。 | 实现了 Schema + rules/limits/patterns + permission catalog;starter 与 ZIP 明确保留手写。 |
| CLI 只能从源码仓库路径运行。 | 增加 npm files 白名单、bin aliases、public publish metadata 与发布闸门;真实上传仍未执行。 |
9心智模型补丁
10新词表
| 契约生成 | |
|---|---|
structure layer | 由 Rust 类型派生的字段、required 和 enum 形状。 |
semantic layer | 由 Rust 常量导出的限制、条件关系、路径与权限规则。 |
contract exporter | 把 Schema 与语义规则序列化成 JSON 的小 Rust 程序。 |
| CLI / npm | |
|---|---|
generated contract | 随 CLI 包分发、运行时只读的 contract.json。 |
bin inference | npm 根据 package 名与 bin 名推断执行命令。 |
prepublishOnly | npm 发布前闸门;本项目用它同步契约并跑测试。 |
11测试与风险地图
- help/version、init、check、permissions、pack 端到端。
- generated contract 与 Rust exporter 对齐。
- permission levels/actions 与前端配置对齐。
- page/widget/both、未知字段、缺权限、Agent schema、i18n、ZIP 上限。
- npx 包元数据、bin、files 白名单、public publishConfig。
- 真实 my-app:0 条诊断、13 个 package files。
- 🟠 npm registry 实际发布未发生,仅 dry-run。
- 🟡 静态源码分析无法证明动态属性、动态 API 名与运行时行为。
- 🟡 drift test 覆盖生成产物,但不替代后端安装测试。
- ⚪ 未新增 VS Code 插件或实时编辑器诊断。
12验收提示与覆盖声明
contract.json很大但属于生成产物,应通过 exporter 与 drift test 验收,不应逐行手改。my-app是验收样例,不是 CLI runtime 依赖。target/与my-app/dist/是忽略产物,不进入报告或 npm tarball。- lockfile 是 Schema exporter 的机械配套,不改变 CLI 运行调用链。
覆盖范围包括已跟踪后端差异,以及与 Tapp CLI/npx 相关的未跟踪文件:契约规则、Rust exporter、同步脚本、generated contract、CLI 入口/校验/ZIP、npm metadata、测试、my-app、开发文档和 handoff。生成 JSON、Cargo.lock、静态资源按机械或验收材料读取,没有把每个生成字段伪装成独立设计决策。