Skip to content

Commit 56f4aa8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-5087-smtp-transport
2 parents a98f3e9 + 77be690 commit 56f4aa8

13 files changed

Lines changed: 1576 additions & 27 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): 执行器契约面 —— `IMetadataService.matchEndpoint?``IHttpServer.setFallbackHandler?` 可选成员(#5040 执行器 E1)
6+
7+
**纯声明,零行为变更。** 本改动只在 `packages/spec/src/contracts/` 增加两个**可选**契约成员与一个导出类型;仓内没有任何实现体、没有任何接线,现网行为逐字节不变。声明式 `ApiEndpoint` 在 v17 仍被 publish 硬拒(#4936 裁决),本单落的是它未来得以执行所需的契约前件(contract-first 首件)。
8+
9+
**1. `IMetadataService.matchEndpoint?(query: { path, method })`** — 把一次请求的 `method`+`path` 解析为拥有该路由的 `api` 元数据条目,或在无人声明时返回 `undefined`。这是 HTTP dispatcher 在「内建 domain 均未认领」与「答语义 404」之间的那一步。随之导出新类型 `ApiEndpointMatch`:
10+
11+
- `endpoint``ApiEndpointSchema.parse` **之后**的形状 —— 默认值已物化,而非存储里的原始 JSON。作者漏写 `authRequired` 时消费端拿到的是 `true`(schema 默认值),因此消费端永远读不到「缺省」这个中间态,也就不可能把一个缺失的安全默认误读成放行。
12+
- `params` 在 17.x **恒为 `{}`**`ApiEndpointSchema.path` 词表已冻结(ADR-0121),既未定义 `:param` 也未定义 `{param}`,本契约**刻意不发明**模板语法 —— 只存在于实现里的语法就是隐藏方言(Prime Directive #12)。槽位现在就声明出来,是为了将来真要加路径模板时,那是词表的加法,而不是本契约的破坏性变更。
13+
14+
**2. `IHttpServer.setFallbackHandler?(handler: RouteHandler)`** — 传输层兜底 seam:仅当**全部显式注册的路由均未命中**后才被调用。它在结构上不可能遮蔽任何已注册路由,因此零注册顺序依赖 —— 这正是它优于「通配路由」方案的原因,后者由插件 `start()` 顺序下的 first-registration-wins 决定归属,即 ADR-0076 D11「一条路由一个属主」要防的病灶。第二条保证同样载入契约:兜底 handler 收到的 `req.body` **可读**,与 `use()` 中间件契约明确「body 不填充」相反(在 `use()` 处解析 body 会在真正拥有它的路由 handler 之前吃掉请求流)—— 这条差异正是中间件 seam 无法承载动态端点、而必须新增本成员的原因:由 flow 或 `create` 操作支撑的声明式端点必须读 body。
15+
16+
**两者均为可选成员**,消费端按仓内既有惯例以 `typeof x === 'function'` 探测(同 `watch?` / `subscribe?` / `getRawApp?`)。不实现它的 `metadata` 槽位占用者、无法表达 not-found 钩子的适配器,都仍然满足契约,消费端退化到既有的未命中应答。因此对现有实现方**无迁移动作**
17+
18+
生成物影响:`api-surface.json` 新增一行 `ApiEndpointMatch (interface)`(0 breaking / 1 added)。两个新成员是 interface 成员而非导出,不动其余七件生成物。
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): 收敛 `validateStackExpressions` / `validateSecurityPosture` 里读 spec 不声明键的 `??` 别名链 (#5017)
6+
7+
两条规则都以 `input: 'parsed'` 注册,看到的是 `ObjectStackSchema` 解析后的产物。
8+
#4984#5009 清掉了 sharing rule 字段层和 org-axis 规则里的同形读法;这一轮是同族
9+
第三轮,落在另外两个文件。议题点名五条,全包 grep 又找出同形的两条,一并处置:
10+
11+
| 原读法 | spec 事实 | 处置 |
12+
|:--|:--|:--|
13+
| `rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula`(两处) | 四个别名全是 `validation.zod.ts``aliases: { …: 'condition' }` **按名拒绝**的键;canonical 排第三 | 收敛为 `rule.condition` |
14+
| `obj.validations ?? obj.validationRules` | `ObjectSchema` 只声明 `validations`,strict 按名拒绝 | 收敛为 `obj.validations` |
15+
| `rule.condition ?? rule.criteria ?? rule.predicate` | `criteria` 是运行时编译产物 `criteria_json` 的拼法(#3896),`predicate` 直接拒绝 | 收敛为 `sharingRule.condition` |
16+
| `def.reference ?? def.referenceTo` | `field.zod.ts:331``referenceTo` 映射为 `reference` | 收敛为 `def.reference` |
17+
| `action.objectName ?? action.object` | canonical 是 `objectName`;`object` 按名拒绝 | 收敛为 `action.objectName` |
18+
| `obj.sharingModel ?? (obj.security)?.sharingModel` | **`ObjectSchema` 根本没有 `security`** —— OWD 三个拨盘是平铺的,且 strict:嵌套写法被整包拒绝 | **删除整个 fallback** |
19+
| `def.reference ?? def.reference_to` |`referenceTo` | 收敛为 `def.reference` |
20+
21+
对任何能解析的 stack,判定结果不变 —— 三个 example(crm / showcase / todo)与平台
22+
default permission sets 上,改动前后两条规则的 findings 逐字相同。
23+
24+
**其中一条不是死代码,是活着的错。** `rule.expression ?? … ?? rule.condition ?? …`
25+
把 canonical 的 `condition` 排在两个被拒别名之后,所以一条同时写了 `condition`
26+
`expression` 的规则,lint 校验的是 schema 会拒绝的那个,而作者声明的那个**从头到尾
27+
没被看过**:producer 和 consumer 对同一份元数据给出两套说法。测试里重建了旧链来演示
28+
这个差异,而不是只描述它。
29+
30+
真正的代价从来不是漏报,而是误导 —— `object.security.sharingModel` 出现在**安全
31+
linter**里,足以让下一位作者(人或 AI)相信对象级 `security` 信封是真实的授权面。
32+
33+
同时补上两层结构性 meta-guard(#4992 模式,#5018 形状),让下一条死读法在 review
34+
前就红:
35+
36+
- **declared-key guard** —— 规则源码里从每个 surface 上读的键,必须出现在该 surface
37+
自己的 Zod `.shape` 里。扫源码不是扫行为是刻意的:不可达分支没有行为可断言。
38+
- **reachability guard** —— `validateSecurityPosture` 全部 15 个 `findings.push`
39+
落点都必须被一条 schema **不报 `unrecognized_keys`** 的 fixture 触达。判据不是
40+
#5018`safeParse` 全绿,而这正是这条规则的特点:它被文档明确设计为也跑在
41+
parse 前,好让 `os lint` 对 zod 会拒绝的****(`sharingModel: 'read'`)给出更
42+
好的信息。被拒的****和被拒的****是两回事 —— 后者在 parsed 路径上压根到不了。
43+
44+
七条读法各自做过变异测试:任意一条加回去,都至少有一条测试转红。

packages/cli/test/authoring-rule-command-parity.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,13 @@ const CASES: ReadonlyArray<{ rule: string; blindTo: readonly AuthoringCommand[];
8787
rule: 'expression-invalid',
8888
blindTo: ['lint'],
8989
stack: withBaseline({
90-
objects: [{ name: 'parity_lead', label: 'Lead', sharingModel: 'private', fields: { lead_score: { type: 'number', label: 'Score' } }, validations: [{ name: 'r', expression: 'lead_score > 100' }] }],
90+
// The planted defect is the BARE `lead_score` (a record-scoped predicate
91+
// binds fields under `record`, so this silently evaluates to null) — not
92+
// the key it is written under. Spelled `condition`, which is the only key
93+
// `validation.zod.ts` declares: `expression` is one of the four names it
94+
// rejects outright, so a fixture using it planted TWO defects and let the
95+
// rule under test read a stack `os validate` would never accept (#5017).
96+
objects: [{ name: 'parity_lead', label: 'Lead', sharingModel: 'private', fields: { lead_score: { type: 'number', label: 'Score' } }, validations: [{ type: 'script', name: 'r', message: 'Score out of range', condition: 'lead_score > 100' }] }],
9197
}),
9298
},
9399
{

packages/lint/src/runtime-gate.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ describe('runtime publish gate (#4463)', () => {
122122
{
123123
name: 'leave_request',
124124
fields: { owner: { type: 'text' } },
125-
validationRules: [{ name: 'bad', expression: 'record.owner ==', message: 'x' }],
125+
// Spelled with the two keys the spec declares (`validations` /
126+
// `condition`). Written as `validationRules` / `expression` — both
127+
// rejected aliases — the broken CEL was not reachable by the rule at
128+
// all, so this fixture proved the subtraction worked by having nothing
129+
// to subtract (#5017).
130+
validations: [{ type: 'script', name: 'bad', message: 'x', condition: 'record.owner ==' }],
126131
},
127132
];
128133

@@ -147,7 +152,12 @@ describe('runtime publish gate (#4463)', () => {
147152
{
148153
name: 'leave_request',
149154
fields: { owner: { type: 'text' } },
150-
validationRules: [{ name: 'bad', expression: 'record.owner ==', message: 'x' }],
155+
// Spelled with the two keys the spec declares (`validations` /
156+
// `condition`). Written as `validationRules` / `expression` — both
157+
// rejected aliases — the broken CEL was not reachable by the rule at
158+
// all, so this fixture proved the subtraction worked by having nothing
159+
// to subtract (#5017).
160+
validations: [{ type: 'script', name: 'bad', message: 'x', condition: 'record.owner ==' }],
151161
},
152162
];
153163
const result = runRuntimeAuthoringRules({

0 commit comments

Comments
 (0)