@antprofuse/saddle-islands-tools 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -4
- package/tools/adapters/islands/trigger_source.py +53 -0
- package/tools/cli/backend.mjs +24 -0
- package/tools/cli/main.py +35 -0
- package/tools/cli/minifish.mjs +38 -0
- package/tools/cli/minifish.py +52 -0
- package/tools/core/package-identity.mjs +23 -0
- package/tools/core/package_identity.py +58 -0
- package/tools/core/semantic_identity.py +149 -0
- package/tools/core/test_semantic_identity.py +136 -0
- package/tools/docs/README.md +42 -0
- package/tools/docs/backend-testing.md +36 -0
- package/tools/docs/minifish-testing.md +32 -0
- package/tools/docs/semantic-identity.md +61 -0
- package/tools/modules/backend/actions.mjs +10 -0
- package/tools/modules/backend/assert.mjs +20 -0
- package/tools/modules/backend/context-receiver.py +30 -0
- package/tools/modules/backend/facility.test.mjs +44 -0
- package/tools/modules/backend/grouped_context.py +100 -0
- package/tools/modules/backend/grpc.mjs +57 -0
- package/tools/modules/backend/index.mjs +9 -0
- package/tools/modules/backend/input-tree.mjs +14 -0
- package/tools/modules/backend/logs.mjs +24 -0
- package/tools/modules/backend/logs.test.mjs +28 -0
- package/tools/modules/backend/metadata.mjs +66 -0
- package/tools/modules/backend/package-lock.json +362 -0
- package/tools/modules/backend/package.json +15 -0
- package/tools/modules/backend/paths.mjs +11 -0
- package/tools/modules/backend/product.mjs +48 -0
- package/tools/modules/backend/rejection-logs.mjs +33 -0
- package/tools/modules/backend/report.mjs +13 -0
- package/tools/modules/backend/report.test.mjs +13 -0
- package/tools/modules/backend/run.mjs +50 -0
- package/tools/modules/backend/util.mjs +24 -0
- package/tools/modules/minifish/checks/guard.test.mjs +13 -0
- package/tools/modules/minifish/checks/package.test.mjs +23 -0
- package/tools/modules/minifish/compiler/build-case.mjs +39 -0
- package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
- package/tools/modules/minifish/compiler/config.mjs +29 -0
- package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
- package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
- package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
- package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
- package/tools/modules/minifish/harness/batch_cases.py +91 -0
- package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
- package/tools/modules/minifish/harness/batch_report.py +104 -0
- package/tools/modules/minifish/harness/browser.mjs +146 -0
- package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
- package/tools/modules/minifish/harness/capture_contract.py +78 -0
- package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
- package/tools/modules/minifish/harness/carriers.mjs +49 -0
- package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
- package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
- package/tools/modules/minifish/harness/component_runtime.py +125 -0
- package/tools/modules/minifish/harness/contract-value.mjs +32 -0
- package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
- package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
- package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
- package/tools/modules/minifish/harness/foreground.mjs +20 -0
- package/tools/modules/minifish/harness/header-layout.mjs +47 -0
- package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
- package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
- package/tools/modules/minifish/harness/host-language.mjs +73 -0
- package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
- package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
- package/tools/modules/minifish/harness/image-loading.mjs +35 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
- package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
- package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
- package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
- package/tools/modules/minifish/harness/jsapi.mjs +92 -0
- package/tools/modules/minifish/harness/loading-input.mjs +52 -0
- package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
- package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
- package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
- package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
- package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
- package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
- package/tools/modules/minifish/harness/native-state.mjs +14 -0
- package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
- package/tools/modules/minifish/harness/navigation.mjs +19 -0
- package/tools/modules/minifish/harness/network_isolation.py +134 -0
- package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
- package/tools/modules/minifish/harness/playwright.mjs +1 -0
- package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
- package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
- package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
- package/tools/modules/minifish/harness/product_identity.py +8 -0
- package/tools/modules/minifish/harness/project_config.py +14 -0
- package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
- package/tools/modules/minifish/harness/region-order.mjs +16 -0
- package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
- package/tools/modules/minifish/harness/request-guard.mjs +65 -0
- package/tools/modules/minifish/harness/request-plan.mjs +124 -0
- package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
- package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
- package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
- package/tools/modules/minifish/harness/run-batch.py +133 -0
- package/tools/modules/minifish/harness/run-platform.py +293 -0
- package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
- package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
- package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
- package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
- package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
- package/tools/modules/minifish/harness/target-actions.mjs +120 -0
- package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
- package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
- package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
- package/tools/modules/minifish/harness/test_common_report.py +47 -0
- package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
- package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
- package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
- package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
- package/tools/modules/minifish/identity.py +6 -0
- package/tools/modules/minifish/package-lock.json +74 -0
- package/tools/modules/minifish/package.json +10 -0
- package/tools/modules/minifish/sdk/package-lock.json +57745 -0
- package/tools/modules/minifish/sdk/package.json +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 独立工具集接入
|
|
2
|
+
|
|
3
|
+
本说明随工具原包分发,供体系使用者阅读。工具包包含根`package.json`和`tools/`,不包含Skills、业务代码或维护者文档。使用方分别取得冻结的角色Skill和兼容工具包。
|
|
4
|
+
|
|
5
|
+
## 公开入口
|
|
6
|
+
|
|
7
|
+
| 工作 | 命令入口 | 说明 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| 语义身份生成、登记、检查 | `python3 TOOL_ROOT/tools/cli/main.py identity` | [语义身份](semantic-identity.md) |
|
|
10
|
+
| 后端场景列举与执行 | `python3 TOOL_ROOT/tools/cli/main.py backend` | [后端接入](backend-testing.md) |
|
|
11
|
+
| Minifish编译、制品、运行、报告 | `python3 TOOL_ROOT/tools/cli/main.py minifish` | [Minifish接入](minifish-testing.md) |
|
|
12
|
+
|
|
13
|
+
`--help`展示入口;需要运行参数的命令按模块说明调用。没有独立Web模块、完整设计校验器或顶层report命令。模块只加载自身执行依赖,后端无需安装Minifish SDK。使用者不导入旧Skill工具路径,不另写执行器或报告器。
|
|
14
|
+
|
|
15
|
+
## 冻结与校验
|
|
16
|
+
|
|
17
|
+
从可信交接取得完整Git commit、独立原包和包外身份清单。先核对公开入口与校验器,再校验完整原包;不能仅执行待验证包生成自己的“可信”清单。构建清单中的`kind: local-build`不证明来源,须逐文件与指定Git提交核对。
|
|
18
|
+
|
|
19
|
+
身份清单字段:
|
|
20
|
+
|
|
21
|
+
- `repository`:可信仓库来源。
|
|
22
|
+
- `commit`:完整40位Git提交。
|
|
23
|
+
- `packagePath`:当前独立工具制品对应仓库根,值为`.`。
|
|
24
|
+
- `package`、`version`:与根package.json完全相同。
|
|
25
|
+
- `files`:所有原包文件的相对路径到SHA256映射,包括隐藏受信文件;不包含仓库维护用.gitignore、依赖安装目录或生成的清单自身。Git与npm工具制品使用相同清单。
|
|
26
|
+
- `dependencyRoots`:以下精确安装边界,顺序固定;声明边界不代表必须安装全部模块。
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
[
|
|
30
|
+
{"path":"tools/modules/backend/node_modules","packageJson":"tools/modules/backend/package.json","lock":"tools/modules/backend/package-lock.json"},
|
|
31
|
+
{"path":"tools/modules/minifish/node_modules","packageJson":"tools/modules/minifish/package.json","lock":"tools/modules/minifish/package-lock.json"},
|
|
32
|
+
{"path":"tools/modules/minifish/sdk/node_modules","packageJson":"tools/modules/minifish/sdk/package.json","lock":"tools/modules/minifish/sdk/package-lock.json"}
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Node校验器为`tools/core/package-identity.mjs`,Python校验器为`tools/core/package_identity.py`。来源冻结由调用方负责;入口接收该外部清单,运行前拒绝缺失、篡改、额外文件、符号链接和错误锁边界。不要把完整仓库checkout直接当独立工具包,也不要往原包放项目配置、报告或Python缓存。
|
|
37
|
+
|
|
38
|
+
后端使用`--identity`,Minifish使用`--tool-source`,两者使用相同工具身份格式。语义身份命令是设计工具,调用前同样通过交接核验工具来源。当前包没有自动下载安装或在线版本选择机制。
|
|
39
|
+
|
|
40
|
+
## 版本切换
|
|
41
|
+
|
|
42
|
+
新接入需更新项目薄入口、公共模块引用、配置中的工具身份与清单路径。项目用例、预期、Mock和产品实现保留在项目中;先完成接入验证,再切换正式批次。同批固定工具版本,已在运行的批次不替换包。业务兼容验证不能以工具自身合成控制代替。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# 后端测试工具接入
|
|
2
|
+
|
|
3
|
+
独立工具包公开`python3 <tool-root>/tools/cli/main.py backend`和`tools/modules/backend/index.mjs`。依赖由 `tools/modules/backend/package-lock.json` 独立固定,安装 `npm ci --ignore-scripts --prefix <package>/tools/modules/backend`。分发包含根package.json与tools目录、锁文件和Python接收适配,不要求调用方工作目录可写;执行输出在包外,锁定依赖安装在模块node_modules。
|
|
4
|
+
|
|
5
|
+
项目通过已推送 Git 原始包的仓库、完整 commit、包目录、版本及逐文件摘要固定来源。npm 只作备份。同批不能替换工具,不使用旧项目实现回退。
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python3 <frozen-package>/tools/cli/main.py backend --project <suite-root> --repository <repository-root> --adapter <project-adapter.mjs> --identity <tool-source.json> --list
|
|
9
|
+
python3 <frozen-package>/tools/cli/main.py backend --project <suite-root> --repository <repository-root> --adapter <project-adapter.mjs> --identity <tool-source.json> --binary <real-executable> --out <fresh-external-output> [--case <id>]
|
|
10
|
+
npm run selftest --prefix <frozen-package>/tools/modules/backend
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
所有根、适配、协议、Service、启动 app/alias 与制品均为显式输入。适配和协议预检真实路径,拒绝未声明根、符号链接逃逸、缺配置或缺制品。项目源依据与 Islands 公开生成工具仍由项目声明;本包不复制 Islands 或 Saddle 运行时。
|
|
14
|
+
|
|
15
|
+
## 适配合同
|
|
16
|
+
|
|
17
|
+
适配模块导出 `project(projectRoot)`,返回 `scenarios`、`proto`、`rpc`、`process`、`frozen`、`inputFiles`、`database`、`databaseLogPolicy`、`prepare` 和 `execute`。
|
|
18
|
+
|
|
19
|
+
- `inputFiles`: 显式项目输入依赖闭包(场景、Mock、契约、适配及其导入模块),启动前检查全部路径/缺失文件并记录摘要。runner另记录适配、协议、完整场景集合与真实制品摘要。
|
|
20
|
+
- `rpc`: 显式 `serviceName`、`match(plan, actual)`、`expectedRequest(plan, actual)` 及 `dynamicFields`。工具始终校验完整 protobuf schema、严格 wire、全部非动态预期字段、所有权、次数、因果和 metadata。动态业务字段需显式声明,不能修改其他冻结预期字段。
|
|
21
|
+
- `process`: 显式 app/alias,必要的 `encodeRequest(request, envelope, options)`、DB 参数及公开配置片段。业务原始 JSON 特值由项目 encoder 提供;工具统一经 Saddle HTTP 入口注册请求所有权。
|
|
22
|
+
- `prepare(scene)`: 返回冻结的 `rounds`,每轮包含 `kind`(set/append)、完整 `calls` 和 `ingress`(operation/request/user/options)。可声明 `allowMissingOnRejection`。并发请求及重入的业务构造在项目中完成。
|
|
23
|
+
- `execute(scene, actions)`: 只得到受控 app.call、RPC set/append/arrived/release、断言与日志函数,以及当前 evidence、abort 和输出目录。公共 runner 检查每次计划及 HTTP 动作逐字符合 prepare 结果,拒绝多次、漏次及改写,统一收集所有 pending 动作、最后强制消费与 metadata 审计。切换轮次前复核上一轮;项目不能把通用守卫改成宽松模式。
|
|
24
|
+
- `databaseLogPolicy`: 显式 disposition 和 forbidCalls;无业务数据库只是某项目声明,不成为其他项目运行默认。
|
|
25
|
+
|
|
26
|
+
decimal、nullable、请求布尔及 HTTP 错误 envelope 按冻结校验语义搬迁,不在迁移中扩展或收紧。业务 service、字段匹配、operation→契约映射、用例/Mock 计划及覆盖支持映射留项目。通用日志与资源回收属于工具;具体无 DB 结论属于项目。
|
|
27
|
+
|
|
28
|
+
业务通过、框架观察、设施控制分别计数。失败、未执行、中断和清理错误保留在散文件证据中,清理失败不能标 passed。工具使用实际制品摘要、冻结来源与原场景 ID 生成报告,不从历史绿灯继承本轮结果。
|
|
29
|
+
|
|
30
|
+
完整 Git 原始包可逐文件导出并直接传播,无需 `.git`,也可置于其他 Git 仓库内。消费项目的 `tool-source.json` 固定可信仓库、完整提交、版本与全部原包文件 SHA256;项目先验证包内身份验证器摘要,再加载验证器核对完整文件清单。缺失、变更、未列文件和符号链接均拒绝,Git HEAD 不作为运行前提。原包不得自行生成身份清单作为信任来源。保留所有受信制品文件(仓库维护用.gitignore不进入工具制品),仅在 tools/modules/backend 执行 `npm ci --ignore-scripts` 安装受信锁依赖,项目薄入口显式指向独立tool-root,调用统一CLI;旧Skill工具路径不再提供隐式回退。node_modules 为锁安装产物,不属于原包源码清单;不要手工修改它。
|
|
31
|
+
|
|
32
|
+
## 包与信任边界
|
|
33
|
+
|
|
34
|
+
使用方获取的是仅含package.json及tools/的独立制品,不是整个体系源码仓。工具不包含Skill、维护者docs或业务项目。运行前从可信交接验证入口和验证器摘要,再用项目外置的完整文件清单验证整个包;工具不能自证来源。构建脚本输出的local-build清单仅供构建核对,不证明提交或发布。
|
|
35
|
+
|
|
36
|
+
后端执行依赖只安装本模块锁,不加载Minifish SDK。所有命令参数及适配合同在本页定义。CLI参数错误或工具身份错误须在业务启动前失败。`--help`只描述接口,不执行身份校验或运行依赖加载。业务项目必须更新其显式工具路径和可信文件清单后才能消费新包。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Minifish public tools
|
|
2
|
+
|
|
3
|
+
Primary distribution: reviewed full Git SHA of `profuse/saddle-islands`, independent tools distribution with root package.json and tools/. Consumers pin commit, package path/version and every original file hash in a trusted manifest outside the package. npm is backup distribution; execution must not resolve a branch or `latest`. Export all original files, including hidden tracked files. Git metadata is optional: standalone exports and exports inside unrelated Git repositories use the same verified content identity.
|
|
4
|
+
|
|
5
|
+
Install compiler dependencies from `tools/modules/minifish/package-lock.json` using `npm ci --ignore-scripts` in an external directory containing that package.json and lock. The official SDK has a separate `sdk` lock; install with `npm ci --legacy-peer-deps --ignore-scripts` to preserve its original legacy peer graph. Runtime commands never implicitly install dependencies.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
python3 PACKAGE/tools/cli/main.py minifish build --tool-source TRUSTED_MANIFEST --project-root PROJECT --config PROJECT/minifish.config.json --definition PROJECT/definitions/CASE.case.json --output OUT --tool-node-modules COMPILER/node_modules
|
|
9
|
+
python3 PACKAGE/tools/cli/main.py minifish plugin --tool-source TRUSTED_MANIFEST --project-root PROJECT --config PROJECT/minifish.config.json --plan PLUGIN_PLAN.json --source PLUGIN_SOURCE --sdk-node-modules SDK/node_modules --output PLUGIN_OUT
|
|
10
|
+
python3 PACKAGE/tools/cli/main.py minifish batch --tool-source TRUSTED_MANIFEST --project-root PROJECT --config PROJECT/minifish.config.json --manifest MANIFEST.json --product PRODUCT --node-modules SDK/node_modules --report-dir REPORT --screenshots all --plugin-artifact-map PLUGIN_MAP.json --slirp4netns SLIRP --slirp-library-path LIBS
|
|
11
|
+
python3 PACKAGE/tools/cli/main.py minifish report --tool-source TRUSTED_MANIFEST --summary REPORT/batch-summary.json --output REPORT
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`report` writes into an existing external report directory; batch execution already creates that directory.
|
|
15
|
+
|
|
16
|
+
`package` accepts a compiled plan via `--plan`; `run` is the single-worker runtime entry. Generated independent packages have their own locked Playwright closure: run `npm ci --ignore-scripts` there. Batch manifests contain exact `{id,package}` records; artifact maps associate exact IDs with precompiled plugin directories. Missing/mismatched plugin artifacts fail before workers or reports are created.
|
|
17
|
+
|
|
18
|
+
Schema-1 config explicitly supplies definitions/catalog, compiler adapter, adapter roots, observation root/modules/import capability set, runtime adapters, page route, plugin source and repository root. Business source revisions, semantic mappings, fixture schemas, request packet policies and project-specific observations stay in project adapters. Common request counting, held timing, native sampling, compilation, cleanup and reporting live here. Runtime imports close over the tool root and declared adapter roots. Traversal, symlink aliases, unknown imports and undeclared observations are rejected; observation dependencies pass the TypeScript AST capability gate.
|
|
19
|
+
|
|
20
|
+
Outputs belong outside source repositories and package. Read-only packages support external dependencies via `--tool-node-modules`. The consumer must verify the complete package against its external trusted manifest before loading any package module or verifier. The project trust bootstrap validates the entrypoint and verifier before calling the unified CLI. Package entries additionally require `--tool-source EXTERNAL_MANIFEST`; they verify the verifier digest before loading it and check all original files, package name/version, required executables and all three module locks. Unlisted files and source symlinks are rejected. Only the manifest-declared, locked `tools/modules/backend/node_modules`, `tools/modules/minifish/node_modules` and `tools/modules/minifish/sdk/node_modules` installation boundaries are excluded, alongside optional root `.git`. Python bytecode writes are disabled. External compiler/SDK dependency roots must have the exact trusted lock. Output records verified content identity, trusted repository/commit/version, manifest/source digests and source/bundled hashes.
|
|
21
|
+
|
|
22
|
+
Behavior defaults to `MINIFISH_EXECUTION_ROLE=behavior`. Separate held image capture uses `MINIFISH_EXECUTION_ROLE=held-artifact`, produces `artifact-only`, records omitted release/navigation boundaries and never adds a behavior pass. Reports embed local evidence as data URLs and distinguish invalid, historical, unassociated and artifact-only evidence.
|
|
23
|
+
|
|
24
|
+
Node controls: `node --experimental-vm-modules --test PACKAGE/tools/modules/minifish/checks/*.test.mjs`. Python controls live under `tools/modules/minifish/harness/test_*.py`; project semantic controls stay with adapters. Do not run obsolete controls consuming unfrozen or forbidden inputs.
|
|
25
|
+
|
|
26
|
+
Batch concurrency defaults to 8; omitting `--workers` and specifying `--workers 8` are equivalent. Fewer cases/shards naturally cap the effective concurrency without empty workers. `--workers N` accepts 1..16, but any non-8 value requires a nonempty `--workers-reason TEXT` and is rejected before reading inputs or starting workers when missing. Public forwarding preserves both flags; use `python3 PACKAGE/tools/cli/main.py minifish batch --tool-source EXTERNAL_MANIFEST --help` for the same contract.
|
|
27
|
+
|
|
28
|
+
Eight is the public tool default, not a universal hardware optimum. Lower concurrency only with resource capacity, new OOM, or clustered startup/missing-receipt evidence, recording that evidence in the reason. Ordinary business assertion failures do not justify lowering concurrency. Values above 8 require an explicit resource experiment purpose. A configuration proven to exceed current capacity must not be retried without changed resource conditions. Keep environment-specific measurements and failure counts in project reports, not universal policy. No automatic whole-batch retry or timeout change accompanies this default. Plugin partitioning is unchanged.
|
|
29
|
+
|
|
30
|
+
Both `batch-input.json` and `batch-summary.json` contain `concurrencyPolicy`: `defaultWorkers`, `requestedWorkers`, `actualConcurrency` (effective simultaneous worker upper bound, not a measured peak), `workersReason`, and truthful `shardSizes` including plugin tails. The summary's `concurrency` uses that effective upper bound; its worker records retain every actual shard and lifecycle receipt.
|
|
31
|
+
|
|
32
|
+
Legacy component-host integrations must supply an explicit `repositoryRoot` to `component_runtime.main(prepare, sourceRoot, projectRoot, repositoryRoot)`. No directory-depth inference is performed. Current case packages use the public `run` or `batch` commands.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# 语义身份:权威算法与追加登记
|
|
2
|
+
|
|
3
|
+
本规范的算法由独立工具集的语义身份模块实现。消费者使用冻结版本的统一工具入口;禁止自行重写、翻译或截短 ID。算法由唯一实现、固定测试向量和 Git 管理,不在数据内部设置版本。未经单独设计不支持其他对象类型。
|
|
4
|
+
|
|
5
|
+
## 后端契约身份
|
|
6
|
+
|
|
7
|
+
身份对象必须且仅包含五个字符串字段:`kind` 固定 `backend_contract`,以及 `business`、`domain`、`page`、`trigger`。
|
|
8
|
+
|
|
9
|
+
- business 逐字取正式业务根目录名;domain 逐字取 Spec 领域标识,page 逐字取该领域正式页面标识。登记范围是业务根;不宣称不同业务根中的任意同名项目全球唯一。
|
|
10
|
+
- trigger 使用页面有效声明模型内的 RFC 6901 JSON Pointer,逐段引用 Spec 原文字段/命名映射;`~`、`/` 分别转义为 `~0`、`~1`。它定位正式触发声明,不取描述文本,不翻译命名,不引用输出代码。初始化示例 `/页面/页面初始化事务`;交互定位到其完整组件声明的 `交互`。
|
|
11
|
+
- 定位必须唯一解析到该页面的一个语义触发。一个交互声明含不同触发时不能把整段当作一个起点;必须使用源头正式可区分的位置,并记录依据。不能自行编号、取第一个调用、发明 purpose,不能将列表重排视为语义不变。
|
|
12
|
+
- 页面返回和初始化是不同宿主事件;仅继承初始化配置不等于它们是相同起点。缺省生效的事件只能按当前 Islands 明确的语言规则定位,并在契约 callSites 留下该规则来源,不伪造 Spec 显式声明。没有可解析来源时反馈具体缺口。
|
|
13
|
+
- 中断前的连续请求可聚合;中断后的后续请求以正式续接位置区分。不要为同一连续触发按实体拆接口,也不要跨不同触发复用契约身份。
|
|
14
|
+
- 组件树中的祖先分支若属于声明路径必须保留,不能误删而造成同名组件混淆;测试样例、播放状态、场景选择和运行数据不进入身份。
|
|
15
|
+
- 非身份说明、字段结构、测试与执行结果不参与摘要。正式领域/页面/触发位置变化创建新身份,旧登记保留;这是身份变更,不是偷偷修改登记。无页面的服务入口不伪造页面,本机制暂不覆盖。
|
|
16
|
+
|
|
17
|
+
## 显式声明与语言缺省来源
|
|
18
|
+
|
|
19
|
+
有效声明模型是原始 Spec 加上当前 Islands 明确规定的缺省行为,不是设计者补造业务。trigger 标识生效事件;callSites.triggerSource 独立保存其实际证据,不参与摘要。显式/缺省配置切换但事件不变时,身份不变。
|
|
20
|
+
|
|
21
|
+
- 显式位置:`triggerSource: {kind: explicit, path: <页面源文件>, pointer: <与trigger相同>}`,要求严格解析且归属该页面。
|
|
22
|
+
- 页面返回缺省:trigger 固定使用源语言原名 `/页面/页面返回时事务`;`triggerSource: {kind: languageDefault, path: <页面源文件>, pointer: /页面, rule: {path: docs/language/visual.md, heading: 页面初始化事务与终点响应}}`。要求返回键确实缺省、页面映射存在,冻结 Islands 该章节明确规定缺省返回复用初始化。实际初始化配置若缺省,同样按语言规定为空配置;不能伪造返回键已在原文件出现。
|
|
23
|
+
- 上述 languageDefault 只允许该已核实的页面返回规则;不能用于为任意不可解析位置兜底。其他缺省事件必须先补正式源规则与校验。显式空返回配置 `{}` 属于 explicit,不能继续当作继承。
|
|
24
|
+
- `path` 为当前项目源文件位置;rule.path 相对冻结 Islands 根,heading 为正式章节原文。版本/提交及复核证据留任务输入快照,不加入业务身份或契约仓库跨分支引用。
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## 精确算法
|
|
28
|
+
|
|
29
|
+
1. 拒绝未知/缺失字段、非字符串、空字符串、首尾 Unicode White_Space(固定为 U+0009–000D、0020、0085、00A0、1680、2000–200A、2028、2029、202F、205F、3000)、U+0000–001F、孤立代理码位。大小写敏感,不做 trim 或翻译。
|
|
30
|
+
2. trigger 必须以 `/` 开头,且 `~` 后只能为 `0` 或 `1`,按 RFC 6901 解释;完整源解析由项目检查器完成。字符串统一 Unicode NFC。字段名固定 ASCII;按字段名字典序排序。
|
|
31
|
+
3. 生成 UTF-8 JSON:无 BOM,无额外空白,无末尾换行;中文不转义,双引号和反斜线采用 JSON 转义。当前算法不接受数值、数组或嵌套对象,避免依赖各语言数值/集合序列化差异。
|
|
32
|
+
4. 对上述字节计算 SHA-256,取摘要前16字节,以32个小写十六进制字符表示,前置 `api_`。完整 ID 固定36字符。
|
|
33
|
+
5. 截断摘要不保证数学上绝无碰撞;登记时发现同 ID 不同归一化身份立即拒绝,禁止加随机后缀、临时加长或覆盖历史记录。
|
|
34
|
+
|
|
35
|
+
合成固定向量(仅为工具测试,不是业务默认;单行 JSON 无尾换行):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{"business":"example-app","domain":"示例领域","kind":"backend_contract","page":"示例页面","trigger":"/页面/页面初始化事务"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
期望 ID:`api_c6267626643566cd7945122e2bd54b73`。其他正反例在工具集语义身份模块旁维护。
|
|
42
|
+
|
|
43
|
+
## 登记表
|
|
44
|
+
|
|
45
|
+
业务根 `code/identity-registry.json` 进入 Git,形状为 `{"<id>":<归一化身份对象>}`。只新增,不删除、不修改已有记录;保存 identity 的完整源语义定位;不额外保存源文件路径、显示文案、状态或时间戳。不包含 identityVersion 或 registryVersion。
|
|
46
|
+
|
|
47
|
+
相同身份再次登记幂等;同一身份在当前契约集合出现两份定义则重复报错。契约退休仅移除契约文件,登记记录永存。索引只用于身份一致性与历史碰撞检测,不承担业务路由或运行时查询。
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
python3 <tool-repository>/tools/cli/main.py identity generate identity.json
|
|
51
|
+
python3 <tool-repository>/tools/cli/main.py identity register identity.json --registry code/identity-registry.json
|
|
52
|
+
python3 <tool-repository>/tools/cli/main.py identity check --registry code/identity-registry.json --baseline /tmp/base-registry.json --active /tmp/active-contract-identities.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
active 是从当前**全部**后端契约严格解析导出的 `[{"id":...,"identity":...}]`,不是手工挑选清单。项目契约校验器负责 YAML 安全解析、全量发现以及来源可解析性;本工具不假称校验业务来源。
|
|
56
|
+
|
|
57
|
+
baseline 必须从真实目标分支的共同基线提取,不能用待审当前文件代替。首次引入时需核实 Git 基线确实无登记表才使用空表。分支合并取登记并集,不解决为“只保留一侧”;Owner 合并后重新检查完整集合及目标分支历史。登记变更串行执行。
|
|
58
|
+
|
|
59
|
+
现有语义命名 ID 不得静默改成摘要。项目迁移必须同步文件、契约引用、客户端调用、后端入口、日志和测试,按交付范围评审。仅更新 Skill 不代表项目已经迁移。
|
|
60
|
+
|
|
61
|
+
来源检查由工具集Islands适配器提供 `validate_trigger_source(identity, triggerSource, document, page_path=实际源文件路径, language_guide=冻结Islands指南全文)`。document 必须由当前页面真实YAML严格解析,不用样例或手工构造替代。工具检查页面路径归属、指针解析、缺省前提和指南依据;真实触发语义、完整输入绑定及流程可达性仍由项目设计检查。当前源文件路径采用 Islands 的 `业务根/spec/domains/领域/visual/页面.yaml` 布局,其他布局须明确支持后再消费,不猜别名。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
export function scenarioActions({plan,rpc,app,abort,pending}){
|
|
3
|
+
const planned=structuredClone(plan);assert(planned&&Array.isArray(planned.rounds)&&planned.rounds.length,'explicit controlled rounds required');
|
|
4
|
+
let roundIndex=0,callIndex=0;const receipts=[];
|
|
5
|
+
const canonical=plans=>plans.map(({timeState,...plan})=>plan);
|
|
6
|
+
const begin=(kind,next)=>{const expected=planned.rounds[roundIndex];assert(expected&&expected.kind===kind,'unplanned call plan transition');assert.equal(callIndex,roundIndex,'previous planned HTTP action missing');assert.deepEqual(canonical(next),expected.calls,'adapter changed frozen RPC plan');if(kind==='set'&&roundIndex){rpc.assert();if(rpc.records.length)rpc.assertMetadata();}rpc[kind](next);roundIndex++;};
|
|
7
|
+
const controlledApp=Object.freeze({call(operation,request,user,options={}){const expected=planned.rounds[callIndex]?.ingress;assert(expected&&callIndex<roundIndex,'unplanned HTTP action');const {signal,...inputOptions}=options;assert.deepEqual({operation,request,user,options:inputOptions},expected,'adapter changed frozen HTTP action');assert(!signal||signal===abort.signal,'unowned cancellation signal');callIndex++;const p=app.call(operation,request,user,options).then(result=>{receipts.push(result);return result;});pending.add(p);p.catch(()=>{});return p;}});
|
|
8
|
+
const controlledRpc=Object.freeze({set:next=>begin('set',next),append:next=>begin('append',next),arrived:(...args)=>rpc.arrived(...args),release:()=>rpc.release(),assert:options=>{if(options?.allowMissing)assert(planned.allowMissingOnRejection&&receipts.some(x=>x.body?.success!==true)&&rpc.records.length===0,'unpermitted missing RPC');return rpc.assert(options);},assertMetadata:()=>rpc.assertMetadata(),get records(){return structuredClone(rpc.records);}});
|
|
9
|
+
return {app:controlledApp,rpc:controlledRpc,finish(){assert.equal(roundIndex,planned.rounds.length,'missing planned RPC round');assert.equal(callIndex,planned.rounds.length,'missing planned HTTP action');const allowMissing=planned.allowMissingOnRejection&&receipts.some(x=>x.body?.success!==true)&&rpc.records.length===0;rpc.assert({allowMissing:!!allowMissing});if(rpc.records.length)rpc.assertMetadata();}};
|
|
10
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import YAML from 'yaml';
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
import {resolve} from 'node:path';
|
|
5
|
+
export function decimal(s){assert.equal(typeof s,'string','decimal is exact yuan string');assert(/^-?\d+(?:\.\d{1,2})?$/.test(s),'invalid exact decimal');const neg=s.startsWith('-');const [a,b='']=s.replace(/^-/,'').split('.');return ((BigInt(a)*100n+BigInt(b.padEnd(2,'0')))*(neg?-1n:1n)).toString();}
|
|
6
|
+
export function validate(node,value,types={},request=false){if(value===null){assert(node.nullable,'unexpected null');return null;}switch(node.type){
|
|
7
|
+
case 'discriminatedUnion':{assert(value&&typeof value==='object');const chosen=node.cases[value[node.discriminator.field]];assert(chosen,'unknown stage');return validate(chosen,value,types,request);}
|
|
8
|
+
case 'ref':return validate(types[node.ref],value,types,request);
|
|
9
|
+
case 'object':assert(value&&typeof value==='object'&&!Array.isArray(value));if(request){for(const [k,n] of Object.entries(node.fields))assert(Object.hasOwn(value,k)||n.nullable,'missing nonnullable request field '+k);}else assert.deepEqual(Object.keys(value).sort(),Object.keys(node.fields).sort(),'exact response fields');return Object.fromEntries(Object.entries(node.fields).map(([k,n])=>[k,validate(n,request&&!Object.hasOwn(value,k)?null:value[k],types,request)]));
|
|
10
|
+
case 'array':assert(Array.isArray(value));return value.map(x=>validate(node.items,x,types,request));
|
|
11
|
+
case 'decimal':return decimal(value);
|
|
12
|
+
case 'string':assert.equal(typeof value,'string');assert(Buffer.byteLength(value)<=(node.maxUtf8Bytes??500),'500 UTF8 byte boundary');return value;
|
|
13
|
+
case 'boolean':if(request&&(value===0||value===1))return value===1;assert.equal(typeof value,'boolean');return value;
|
|
14
|
+
case 'enum':assert(node.values.includes(value),'unknown enum');return value;
|
|
15
|
+
default:throw Error(`unhandled contract type ${node.type}`);
|
|
16
|
+
}}
|
|
17
|
+
export function inputShape(value,contract){return validate(contract.input,value,contract.types,true);}
|
|
18
|
+
export function outcome(actual,scenario,contract){const b=actual.body;if(scenario.error==='INVALID_REQUEST'&&actual.status===400){assert.notEqual(b?.success,true);return;}assert.equal(actual.status,200);if(scenario.error){assert.deepEqual(Object.keys(b).sort(),['failure','success']);assert.equal(b.success,false);assert.deepEqual(Object.keys(b.failure).sort(),['code','message']);assert.equal(b.failure.code,scenario.error);assert.equal(typeof b.failure.message,'string');assert(Buffer.byteLength(b.failure.message)<=256);if(scenario.message!==undefined)assert.equal(b.failure.message,scenario.message);assert(!/synthetic boundary failure|stack|grpc-status/i.test(b.failure.message),'raw technical error leaked');}else{assert.deepEqual(Object.keys(b).sort(),['data','success']);assert.equal(b.success,true);assert.deepEqual(validate(contract.output,b.data,contract.types),validate(contract.output,scenario.expected,contract.types));}}
|
|
19
|
+
|
|
20
|
+
export async function readContract(path){return YAML.parse(await readFile(path,'utf8'));}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Test adapter for the unmodified official Saddle 0.3.34 receiver example.
|
|
2
|
+
Native gRPC buffers are transported as hex only across the local process boundary.
|
|
3
|
+
Large extension numbers are tagged for lossless JavaScript assertions/evidence.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from grouped_context import Context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def project(value):
|
|
13
|
+
if isinstance(value, Mapping):
|
|
14
|
+
return {k: project(v) for k, v in value.items()}
|
|
15
|
+
if isinstance(value, (list, tuple)):
|
|
16
|
+
return [project(v) for v in value]
|
|
17
|
+
if isinstance(value, Decimal):
|
|
18
|
+
return {'$decimal': str(value)}
|
|
19
|
+
if type(value) is int and abs(value) > 9007199254740991:
|
|
20
|
+
return {'$integer': str(value)}
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
pairs = json.load(sys.stdin)
|
|
26
|
+
context = Context((key, bytes.fromhex(value)) for key, value in pairs)
|
|
27
|
+
print(json.dumps(project(context.groups), ensure_ascii=False))
|
|
28
|
+
except Exception as error:
|
|
29
|
+
print(type(error).__name__ + ': ' + str(error), file=sys.stderr)
|
|
30
|
+
sys.exit(1)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {mkdtemp,writeFile,mkdir,symlink,rm} from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {resolve} from 'node:path';
|
|
6
|
+
import {protocol,mock,grpc,wireGuard} from './grpc.mjs';
|
|
7
|
+
import {preflight,within} from './paths.mjs';
|
|
8
|
+
import {run} from './run.mjs';
|
|
9
|
+
import {externalOutput,processStart} from './util.mjs';
|
|
10
|
+
import {outcome,decimal,validate} from './assert.mjs';
|
|
11
|
+
import {classifyReport} from './report.mjs';
|
|
12
|
+
const schema='syntax = "proto3"; package orchard.v2; service Inventory {rpc Find (Key) returns (Value);} message Key {optional string token=1;} message Value {optional string text=1;}';
|
|
13
|
+
test('alternate project/protocol names use strict wire, all fields, counts and causal held response',async()=>{
|
|
14
|
+
const dir=await mkdtemp(resolve(tmpdir(),'orchard-'));const proto=resolve(dir,'inventory.proto');await writeFile(proto,schema);
|
|
15
|
+
const config={serviceName:'orchard.v2.Inventory',match:(p,r)=>p.request.token===r.token,expectedRequest:p=>p.request};const m=await mock(proto,config),C=grpc.makeGenericClientConstructor(m.service,'inventory'),client=new C(`127.0.0.1:${m.port}`,grpc.credentials.createInsecure());
|
|
16
|
+
const call=request=>new Promise((yes,no)=>client.Find(request,(e,r)=>e?no(e):yes(r)));
|
|
17
|
+
try{
|
|
18
|
+
m.set([{method:'Find',request:{token:'key'},response:{text:'apple'},hold:true}]);let settled=false;const p=call({token:'key'}).then(x=>{settled=true;return x;});await m.arrived('Find');assert.equal(settled,false);m.release();assert.deepEqual(await p,{text:'apple'});m.assert();
|
|
19
|
+
await assert.rejects(call({token:'key'}));assert.throws(()=>m.assert());m.set([{method:'Find',request:{token:'key'},response:{text:'apple'}}]);assert.throws(()=>m.assert());await assert.rejects(call({token:'bad'}));assert.throws(()=>m.assert());
|
|
20
|
+
const type=m.root.lookupType('orchard.v2.Key'),valid=type.encode({token:'k'}).finish();for(const bad of [Buffer.concat([valid,valid]),Buffer.concat([valid,Buffer.from([16,1])]),valid.subarray(0,valid.length-1)])assert.throws(()=>wireGuard(type,bad));
|
|
21
|
+
await assert.rejects(protocol(proto));await assert.rejects(mock(proto,{serviceName:config.serviceName}));
|
|
22
|
+
}finally{client.close();await m.close();await rm(dir,{recursive:true,force:true});}
|
|
23
|
+
});
|
|
24
|
+
test('missing config/artifact, traversal and symlink escape fail before process startup',async()=>{
|
|
25
|
+
const dir=await mkdtemp(resolve(tmpdir(),'backend-paths-'));const root=resolve(dir,'project');await mkdir(root);const adapter=resolve(root,'adapter.mjs'),proto=resolve(root,'inventory.proto');await writeFile(adapter,'');await writeFile(proto,schema);await writeFile(resolve(dir,'outside.mjs'),'');await symlink(resolve(dir,'outside.mjs'),resolve(root,'escape.mjs'));
|
|
26
|
+
try{await preflight({projectRoot:root,repositoryRoot:dir,adapter,proto,list:true});for(const path of [resolve(dir,'outside.mjs'),resolve(root,'escape.mjs')])await assert.rejects(within(root,path));await assert.rejects(preflight({projectRoot:root,repositoryRoot:dir,adapter,proto,binary:resolve(root,'missing')}));await assert.rejects(run({}));assert.throws(()=>externalOutput(root,resolve(root,'reports')));}finally{await rm(dir,{recursive:true,force:true});}
|
|
27
|
+
});
|
|
28
|
+
test('frozen decimal/null/HTTP error semantics and cleanup failures remain visible',()=>{
|
|
29
|
+
assert.equal(decimal('1.20'),decimal('1.2'));assert.throws(()=>decimal(1.2));assert.throws(()=>validate({type:'string'},null));assert.equal(validate({type:'string',nullable:true},null),null);
|
|
30
|
+
const s={error:'INVALID_REQUEST'};outcome({status:400,body:null},s,{});assert.throws(()=>outcome({status:400,body:{success:true}},s,{}));assert.throws(()=>outcome({status:200,body:{success:true,data:{}}},s,{}));
|
|
31
|
+
assert.equal(classifyReport({scenarios:[{id:'one',status:'passed'}],cleanupErrors:[{name:'child',error:'owned process remains'}]},[{id:'one'}]).status,'infrastructure-failed');
|
|
32
|
+
});
|
|
33
|
+
test('owned public process actually starts and stops without leaking',async()=>{
|
|
34
|
+
const dir=await mkdtemp(resolve(tmpdir(),'backend-child-'));let p;try{p=await processStart(process.execPath,['-e',"process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000)"],dir);await new Promise(r=>setTimeout(r,80));await p.stop();assert.equal((await p.done).code,0);await p.stop();}finally{if(p)await p.stop();await rm(dir,{recursive:true,force:true});}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('controlled actions reject adapter changed expectations, duplicate HTTP and omitted actions',async()=>{
|
|
38
|
+
const {scenarioActions}=await import('./actions.mjs');
|
|
39
|
+
const plan={rounds:[{kind:'set',calls:[{method:'Find',request:{token:'k'},response:{text:'v'}}],ingress:{operation:'lookup',request:{key:'k'},user:'synthetic',options:{}}}]};
|
|
40
|
+
let audited=0;const rpc={set(){},append(){},arrived(){},release(){},assert(){audited++;},assertMetadata(){},records:[]};
|
|
41
|
+
const make=()=>scenarioActions({plan,rpc,app:{async call(){return {status:200,body:{success:true}};}},abort:new AbortController(),pending:new Set()});
|
|
42
|
+
const a=make();assert.throws(()=>a.app.call('lookup',{key:'k'},'synthetic'));assert.throws(()=>a.rpc.set([]));a.rpc.set(plan.rounds[0].calls);assert.throws(()=>a.app.call('lookup',{key:'different'},'synthetic'));await a.app.call('lookup',{key:'k'},'synthetic');assert.throws(()=>a.app.call('lookup',{key:'k'},'synthetic'));assert.throws(()=>a.rpc.assert({allowMissing:true}));a.finish();assert(audited>0);
|
|
43
|
+
const b=make();assert.throws(()=>b.finish());b.rpc.set(plan.rounds[0].calls);assert.throws(()=>b.finish());
|
|
44
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Ordinary gRPC interceptor helper; receives bytes from the native -bin API.
|
|
2
|
+
|
|
3
|
+
Construct one Context per RPC. Store it in that framework's request scope and
|
|
4
|
+
pass only its read-only groups to handlers. No process-global mutable context.
|
|
5
|
+
This helper does not implement a server or change business protobuf messages.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
KEYS = ('trace-info-bin', 'ldc-info-bin', 'request-info-bin', 'profuse-context-bin')
|
|
12
|
+
LIMIT = 4096
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _object(pairs):
|
|
16
|
+
result = {}
|
|
17
|
+
for key, value in pairs:
|
|
18
|
+
if key in result:
|
|
19
|
+
raise ValueError('duplicate context key')
|
|
20
|
+
result[key] = value
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _constant(_):
|
|
25
|
+
raise ValueError('invalid JSON number')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _freeze(value):
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
|
31
|
+
if isinstance(value, list):
|
|
32
|
+
return tuple(_freeze(item) for item in value)
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _encode(value):
|
|
37
|
+
# Decimal avoids a float roundtrip for unknown JSON numbers.
|
|
38
|
+
if isinstance(value, Decimal):
|
|
39
|
+
if not value.is_finite(): raise ValueError('invalid JSON number')
|
|
40
|
+
return str(value)
|
|
41
|
+
if isinstance(value, (dict, MappingProxyType)):
|
|
42
|
+
return '{' + ','.join(json.dumps(key, ensure_ascii=False) + ':' + _encode(item)
|
|
43
|
+
for key, item in value.items()) + '}'
|
|
44
|
+
if isinstance(value, (list, tuple)):
|
|
45
|
+
return '[' + ','.join(_encode(item) for item in value) + ']'
|
|
46
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':'))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Context:
|
|
50
|
+
__slots__ = ('_groups',)
|
|
51
|
+
|
|
52
|
+
def __init__(self, metadata):
|
|
53
|
+
raw = {}
|
|
54
|
+
for key, value in metadata:
|
|
55
|
+
if key not in KEYS: continue
|
|
56
|
+
if key in raw: raise ValueError('duplicate context metadata')
|
|
57
|
+
if not isinstance(value, bytes): raise ValueError('binary metadata required')
|
|
58
|
+
raw[key] = value
|
|
59
|
+
if any(key not in raw for key in KEYS[:3]):
|
|
60
|
+
raise ValueError('missing mandatory context metadata')
|
|
61
|
+
if sum(map(len, raw.values())) > LIMIT:
|
|
62
|
+
raise ValueError('context exceeds 4096 bytes')
|
|
63
|
+
groups = {key: json.loads(value.decode('utf-8'), object_pairs_hook=_object,
|
|
64
|
+
parse_float=Decimal, parse_int=int, parse_constant=_constant)
|
|
65
|
+
for key, value in raw.items()}
|
|
66
|
+
if any(not isinstance(value, dict) for value in groups.values()):
|
|
67
|
+
raise ValueError('context group must be object')
|
|
68
|
+
for key, fields in [(KEYS[0], ('traceId', 'rpcId')),
|
|
69
|
+
(KEYS[1], ('zone', 'idc', 'env')),
|
|
70
|
+
(KEYS[2], ('requestId', 'callId'))]:
|
|
71
|
+
if any(not isinstance(groups[key].get(field), str) or not groups[key][field]
|
|
72
|
+
for field in fields):
|
|
73
|
+
raise ValueError('invalid known context string')
|
|
74
|
+
for field in ('zone', 'idc', 'env'):
|
|
75
|
+
value = groups[KEYS[1]][field]
|
|
76
|
+
if not value.strip() or len(value.encode('utf-8')) > 256:
|
|
77
|
+
raise ValueError('invalid LDC identity')
|
|
78
|
+
deadline = groups[KEYS[2]].get('deadlineUnixMs')
|
|
79
|
+
if type(deadline) is not int or not 0 < deadline <= 9223372036854775807:
|
|
80
|
+
raise ValueError('invalid deadline')
|
|
81
|
+
self._groups = _freeze(groups)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def groups(self):
|
|
85
|
+
return self._groups
|
|
86
|
+
|
|
87
|
+
def child_metadata(self, sequence, deadline_unix_ms):
|
|
88
|
+
if type(sequence) is not int or sequence < 1:
|
|
89
|
+
raise ValueError('positive shared sequence required')
|
|
90
|
+
old = self.groups[KEYS[2]]
|
|
91
|
+
if type(deadline_unix_ms) is not int or not 0 < deadline_unix_ms <= old['deadlineUnixMs']:
|
|
92
|
+
raise ValueError('deadline may only shorten')
|
|
93
|
+
groups = dict(self.groups)
|
|
94
|
+
groups[KEYS[0]] = {**groups[KEYS[0]], 'rpcId': groups[KEYS[0]]['rpcId'] + '.' + str(sequence)}
|
|
95
|
+
groups[KEYS[2]] = {**old, 'callId': old['callId'] + '-' + str(sequence),
|
|
96
|
+
'deadlineUnixMs': deadline_unix_ms}
|
|
97
|
+
values = tuple((key, _encode(value).encode('utf-8')) for key, value in groups.items())
|
|
98
|
+
if sum(len(value) for _, value in values) > LIMIT:
|
|
99
|
+
raise ValueError('context exceeds 4096 bytes')
|
|
100
|
+
return values
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import grpc from '@grpc/grpc-js';
|
|
2
|
+
import loader from '@grpc/proto-loader';
|
|
3
|
+
import protobuf from 'protobufjs';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import {until} from './util.mjs';
|
|
6
|
+
import {observeMetadata,assertMetadata} from './metadata.mjs';
|
|
7
|
+
export async function protocol(path,serviceName) {
|
|
8
|
+
assert(serviceName,'explicit protocol service required');
|
|
9
|
+
const root=await new protobuf.Root().load(path,{keepCase:true});root.resolveAll();
|
|
10
|
+
const definition=loader.loadSync(path,{keepCase:true,defaults:false,oneofs:false});
|
|
11
|
+
const service=definition[serviceName];assert(service);
|
|
12
|
+
return {root,service};
|
|
13
|
+
}
|
|
14
|
+
export function schemaGuard(type,value){
|
|
15
|
+
assert.equal(type.verify(value),null,`invalid ${type.fullName}`);
|
|
16
|
+
for(const [name,v]of Object.entries(value)){const f=type.fields[name];assert(f,`unknown fixture field ${type.fullName}/${name}`);if(v!=null&&f.resolvedType instanceof protobuf.Type)for(const child of f.repeated?v:[v])schemaGuard(f.resolvedType,child);}
|
|
17
|
+
}
|
|
18
|
+
export function wireGuard(type,buffer) {
|
|
19
|
+
const r=protobuf.Reader.create(buffer),seen=new Set();
|
|
20
|
+
while(r.pos<r.len){const tag=r.uint32(),id=tag>>>3,wire=tag&7;const f=type.fieldsById[id];assert(f,`unknown protobuf field ${type.fullName}/${id}`);
|
|
21
|
+
if(!f.repeated){assert(!seen.has(id),`duplicate protobuf field ${id}`);seen.add(id);}
|
|
22
|
+
const expected=f.resolvedType instanceof protobuf.Type || f.type==='string'||f.type==='bytes'?2:0;
|
|
23
|
+
assert.equal(wire,expected,`wrong wire type ${id}`);
|
|
24
|
+
if(expected===2){const b=r.bytes();if(f.resolvedType instanceof protobuf.Type)wireGuard(f.resolvedType,b);else if(f.type==='string')assert.equal(Buffer.from(new TextDecoder('utf-8',{fatal:true}).decode(b),'utf8').length,b.length);}
|
|
25
|
+
else r.uint64();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function mock(proto,{serviceName,match,expectedRequest,dynamicFields=[],requireContext=false}={}) {
|
|
29
|
+
assert.equal(typeof match,'function','explicit project match required');
|
|
30
|
+
assert.equal(typeof expectedRequest,'function','explicit expected request adapter required');
|
|
31
|
+
const {root,service}=await protocol(proto,serviceName);const server=new grpc.Server();const records=[],violations=[];let plans=[],owners=[],currentOwner;let ownerSequence=0,planSequence=0;
|
|
32
|
+
function controlledRequest(plan,actual){const expected=expectedRequest(plan,actual);for(const [key,value]of Object.entries(plan.request))if(!dynamicFields.includes(key))assert.deepEqual(expected[key],value,'adapter changed expected field '+key);for(const key of Object.keys(expected))assert(Object.hasOwn(plan.request,key)||dynamicFields.includes(key),'adapter introduced unconfigured dynamic field '+key);return expected;}
|
|
33
|
+
const handlers={},guarded={};
|
|
34
|
+
for(const [name,def] of Object.entries(service)){
|
|
35
|
+
const requestType=root.lookupService(serviceName).methods[name].resolvedRequestType;
|
|
36
|
+
guarded[name]={...def,requestDeserialize:bytes=>{try{wireGuard(requestType,bytes);return def.requestDeserialize(bytes);}catch(e){violations.push(`invalid wire ${name}: ${e.message}`);throw e;}}};
|
|
37
|
+
handlers[name]=(call,cb)=>{
|
|
38
|
+
const actual={method:name,path:def.path,request:call.request};records.push(actual);
|
|
39
|
+
const candidates=plans.filter(p=>p.method===name&&!p.used&&p.match(call.request));
|
|
40
|
+
const plan=candidates[0];
|
|
41
|
+
try {if(requireContext)actual.context=observeMetadata(call.metadata,call.getDeadline());assert(plan,`unexpected call ${name} ${JSON.stringify(call.request)}`);assert.equal(new Set(candidates.map(p=>p.ownerId)).size,1,'ambiguous expected call ownership');actual.planId=plan.planId;actual.ownerId=plan.ownerId;plan.used=true;assert.deepEqual(call.request,controlledRequest(plan,call.request),`full gRPC parameters ${name}`);if(plan.after)assert(records.slice(0,-1).some(r=>r.method===plan.after&&r.ownerId===plan.ownerId),'causal call order');
|
|
42
|
+
const respond=()=>plan.error?cb({code:plan.error,message:'synthetic boundary failure'}):cb(null,plan.response);
|
|
43
|
+
if(plan.hold)plan.release=respond;else respond();
|
|
44
|
+
}catch(e){if(e.contextRaw)actual.contextFailure={error:e.message,raw:e.contextRaw};violations.push(e.message);cb({code:grpc.status.INVALID_ARGUMENT,message:'mock expectation rejected'});}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
server.addService(guarded,handlers);
|
|
48
|
+
const port=await new Promise((yes,no)=>server.bindAsync('127.0.0.1:0',grpc.ServerCredentials.createInsecure(),(e,p)=>e?no(e):yes(p)));
|
|
49
|
+
const adapt=p=>{const method=root.lookupService(serviceName).methods[p.method];assert(method,`unknown mock method ${p.method}`);schemaGuard(method.resolvedRequestType,p.request);if(p.response)schemaGuard(method.resolvedResponseType,p.response);return ({...p,match:p.match|| (r=>match(p,r))});};
|
|
50
|
+
function addOwner(next){
|
|
51
|
+
currentOwner={ownerId:++ownerSequence,ingress:null};owners.push(currentOwner);
|
|
52
|
+
plans.push(...next.map(p=>({...adapt(p),planId:++planSequence,ownerId:currentOwner.ownerId})));
|
|
53
|
+
}
|
|
54
|
+
function expectations(){return owners.map(owner=>({...owner,plans:plans.filter(p=>p.ownerId===owner.ownerId).map(p=>({planId:p.planId,method:p.method,optional:!!p.optional}))}));}
|
|
55
|
+
return {port,records,violations,service,root,set(next){assert(plans.every(p=>!p.hold||!p.used||p.released),'unreleased held response');plans=[];owners=[];addOwner(next);records.length=0;violations.length=0;},append(next){addOwner(next);},registerIngress(ingress){assert(currentOwner,'set a call plan before ingress');assert(!currentOwner.ingress||!plans.some(p=>p.ownerId===currentOwner.ownerId),'call owner already registered');currentOwner.ingress=structuredClone(ingress);return currentOwner.ownerId;},expectations,assertMetadata(){return assertMetadata(records,expectations());},async arrived(method,count=1){await until(()=>{assert(records.filter(r=>r.method===method).length>=count);return true;},5000);},release(){for(const p of plans)if(p.release&&!p.released){p.released=true;p.release();}},assert({allowMissing=false}={}){assert.deepEqual(violations,[]);if(!allowMissing)assert(plans.every(p=>p.used||p.optional),'missing gRPC call');assert.equal(records.length,plans.filter(p=>p.used).length,'extra gRPC call');},async close(){this.release();await new Promise(r=>server.tryShutdown(r));}};
|
|
56
|
+
}
|
|
57
|
+
export {grpc};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * as assertions from './assert.mjs';
|
|
2
|
+
export * as rpc from './grpc.mjs';
|
|
3
|
+
export * from './util.mjs';
|
|
4
|
+
export * from './product.mjs';
|
|
5
|
+
export * from './metadata.mjs';
|
|
6
|
+
export * from './logs.mjs';
|
|
7
|
+
export * from './rejection-logs.mjs';
|
|
8
|
+
export * from './report.mjs';
|
|
9
|
+
export * from './input-tree.mjs';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {execFile} from 'node:child_process';
|
|
3
|
+
import {promisify} from 'node:util';
|
|
4
|
+
import {readFile} from 'node:fs/promises';
|
|
5
|
+
import {resolve} from 'node:path';
|
|
6
|
+
import {createHash} from 'node:crypto';
|
|
7
|
+
const exec=promisify(execFile);
|
|
8
|
+
export async function sourceTree({repo,revision,prefixes}){
|
|
9
|
+
assert(repo&&revision&&Array.isArray(prefixes)&&prefixes.length,'explicit frozen source configuration required');
|
|
10
|
+
const {stdout}=await exec('git',['-C',repo,'ls-tree','-r','--name-only','-z',revision,'--',...prefixes]);const files=stdout.split('\0').filter(Boolean);assert(files.length);
|
|
11
|
+
const untracked=(await exec('git',['-C',repo,'ls-files','--others','--exclude-standard','--',...prefixes])).stdout;assert.equal(untracked,'','untracked business input file');
|
|
12
|
+
const index=[];for(const file of files){const original=(await exec('git',['-C',repo,'show',revision+':'+file],{encoding:'buffer',maxBuffer:4*1024*1024})).stdout;const current=await readFile(resolve(repo,file));assert.deepEqual(current,original,'frozen source changed '+file);index.push({file,sha256:createHash('sha256').update(current).digest('hex')});}
|
|
13
|
+
return index;
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {readFile} from 'node:fs/promises';
|
|
3
|
+
import {resolve} from 'node:path';
|
|
4
|
+
import {until} from './util.mjs';
|
|
5
|
+
import {assertDecodeRejection} from './rejection-logs.mjs';
|
|
6
|
+
export function assertRequestLogs(logs,actual,{operation,rejected=false,phase,emergency=[],calls=[],database}={}){
|
|
7
|
+
if(phase==='input_decode_rejected')return assertDecodeRejection(logs,actual,{operation,emergency,calls});
|
|
8
|
+
// Trace IDs may be shared by multiple ingress requests. Request-stage events
|
|
9
|
+
// establish ownership of internal spans; root call logs have no request_identity.
|
|
10
|
+
const owned=logs.filter(x=>x.request_identity===actual.ingress.requestId);
|
|
11
|
+
assert(owned.length,'missing request identity events');
|
|
12
|
+
const spans=new Set(owned.map(x=>x.span_id).filter(Boolean));
|
|
13
|
+
const events=logs.filter(x=>x.request_identity===actual.ingress.requestId||(x.trace_id===actual.ingress.traceInfo.traceId&&spans.has(x.span_id)));
|
|
14
|
+
for(const e of events){assert.equal(e.trace_id,actual.ingress.traceInfo.traceId,'request trace attribution');if(e.request_identity!==undefined)assert.equal(e.request_identity,actual.ingress.requestId,'cross-request span contamination');}
|
|
15
|
+
|
|
16
|
+
const root=events.filter(x=>x.call_kind==='external_request');assert(root.some(x=>x.event==='framework.call.started'),'missing root start');
|
|
17
|
+
if(!rejected){assert(root.some(x=>x.event==='framework.call.finished'),'missing root finish');for(const e of root)assert.equal(e.operation,operation,'static entry operation attribution');}
|
|
18
|
+
assert(database&&typeof database.disposition==='string'&&typeof database.forbidCalls==='boolean','explicit database observation policy required');
|
|
19
|
+
assert(events.some(x=>x.event==='framework.resource.finalized'&&x.db_disposition===database.disposition),'database disposition mismatch');
|
|
20
|
+
if(database.forbidCalls)assert(!events.some(x=>x.call_kind==='database'),'unexpected DB operation');
|
|
21
|
+
if(rejected)assert(!events.some(x=>x.call_kind==='handler'||x.stage==='handler'),'rejected route entered handler');
|
|
22
|
+
return events;
|
|
23
|
+
}
|
|
24
|
+
export async function requestLogs(out,actual,options){return until(async()=>{const lines=(await readFile(resolve(out,'logs/saddle.log'),'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse);const emergency=options?.phase==='input_decode_rejected'?(await readFile(resolve(out,'logs/saddle.emergency.log'),'utf8')).trim().split('\n').filter(Boolean).map(JSON.parse):[];return assertRequestLogs(lines,actual,{...options,emergency});},3000);}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {assertRequestLogs} from './logs.mjs';
|
|
4
|
+
const actual={ingress:{traceInfo:{traceId:'t'},requestId:'r'}};
|
|
5
|
+
const op='api-test';
|
|
6
|
+
const sample=(request='r',span='s')=>[
|
|
7
|
+
{trace_id:'t',span_id:span,event:'framework.call.started',call_kind:'external_request',operation:op},
|
|
8
|
+
{trace_id:'t',span_id:span,event:'framework.call.finished',call_kind:'external_request',operation:op},
|
|
9
|
+
{trace_id:'t',span_id:span,request_identity:request,event:'framework.resource.finalized',db_disposition:'not_used'}
|
|
10
|
+
];
|
|
11
|
+
test('request attribution and no DB guards positive and failure controls',()=>{
|
|
12
|
+
const logs=sample();assertRequestLogs(logs,actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}});
|
|
13
|
+
for(const mutate of [x=>x.pop(),x=>x[0].operation='wrong',x=>x[2].db_disposition='returned',x=>x.push({trace_id:'t',span_id:'child',request_identity:'r',call_kind:'database'})]){
|
|
14
|
+
const wrong=structuredClone(logs);mutate(wrong);assert.throws(()=>assertRequestLogs(wrong,actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}}));
|
|
15
|
+
}
|
|
16
|
+
assert.throws(()=>assertRequestLogs(logs.map(x=>({...x,trace_id:'other'})),actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}}));
|
|
17
|
+
});
|
|
18
|
+
test('shared trace cannot lend previous start finish or NotUsed to another request',()=>{
|
|
19
|
+
const previous=sample('previous','old');
|
|
20
|
+
assert.throws(()=>assertRequestLogs(previous,actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}}));
|
|
21
|
+
const combined=[...previous,...sample()];
|
|
22
|
+
assert.deepEqual(assertRequestLogs(combined,actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}}),sample());
|
|
23
|
+
for(const remove of ['framework.call.started','framework.call.finished','framework.resource.finalized']){
|
|
24
|
+
assert.throws(()=>assertRequestLogs([...previous,...sample().filter(e=>e.event!==remove)],actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}}));
|
|
25
|
+
}
|
|
26
|
+
const leaked=sample();leaked.push({trace_id:'t',span_id:'s',request_identity:'previous',event:'framework.resource.finalized',db_disposition:'not_used'});
|
|
27
|
+
assert.throws(()=>assertRequestLogs(leaked,actual,{operation:op,database:{disposition:'not_used',forbidCalls:true}}));
|
|
28
|
+
});
|