@objectstack/lint 17.0.0-rc.3 → 17.0.0-rc.4
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/CHANGELOG.md +1924 -4
- package/dist/index.cjs +1651 -459
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +527 -155
- package/dist/index.d.ts +527 -155
- package/dist/index.js +1601 -440
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +1466 -350
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +1449 -328
- package/dist/runtime.js.map +1 -1
- package/package.json +8 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,1933 @@
|
|
|
1
1
|
# @objectstack/lint
|
|
2
2
|
|
|
3
|
-
## 17.0.0-rc.
|
|
3
|
+
## 17.0.0-rc.4
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 28ad90e: feat(types,cloud-connection,lint,cli): ADR-0120 17.x 收尾 —— `isolated` 安装期姿态硬门(D5e)、D5c 重拼写 advisory、成文契约扫荡与三姿态 conformance (#5081)
|
|
8
|
+
|
|
9
|
+
ADR-0120 17.x 波的第三块,也是最后一块。前两块已在 main 上:#5212(driver 侧
|
|
10
|
+
D3+D4 —— `COALESCE(organization_id, '__global__')` 物化、drift 两侧同步、重复预检)
|
|
11
|
+
与 #5208(spec 词汇 `'organization'` + D5a/D5b lint)。本次补齐三件事:安装期的
|
|
12
|
+
姿态决策点、剩余的成文契约、以及把「一个 app 包跑遍三种姿态」从假设变成测试。
|
|
13
|
+
|
|
14
|
+
**D5e —— 装进 `isolated` 环境时的硬门。** 词汇本身是姿态无关的:作者说的是业务
|
|
15
|
+
边界(`'organization'` 一个组织一份 / `'global'` 整个安装一份),没有任何索引形状
|
|
16
|
+
读姿态。唯一的残留在一个方向上:`isolated` 下组织就是**不同客户**,此时 app 业务
|
|
17
|
+
对象上的 `'global'` 唯一既跨客户过度约束,又变成跨客户的存在性预言机(S10/S14)。
|
|
18
|
+
维护者裁定这是**硬门而非 advisory**:把带 `'global'` 唯一(非 `sys` 对象)的 app
|
|
19
|
+
装进 `isolated` 环境会**停下来并逐索引列出**,安装者(通常是 AI agent)要么确认它
|
|
20
|
+
确实是平台级的,要么改写为 `'organization'`;确认按 ADR-0104 attestation 风格
|
|
21
|
+
留痕在安装清单里(`InstalledManifestEntry.globalUniqueAttestation` —— 确认了什么、
|
|
22
|
+
谁确认的、何时、在哪个姿态下问的),**之后不复问**。
|
|
23
|
+
|
|
24
|
+
- 停下的安装**什么都不留**:先于 hot-register 和任何 ledger 写入,所以作者改完
|
|
25
|
+
元数据可以直接重试,不需要先卸载。
|
|
26
|
+
- 逐索引确认是有牙齿的:`confirmGlobalUniques` 收 `true` 或明确的 id 数组,只确认
|
|
27
|
+
其中一条仍会在剩下的那条上停住。
|
|
28
|
+
- 升级引入的**新**约束会被问,老的答案继续算数。
|
|
29
|
+
- 另一个姿态下给出的确认**不算同意** —— `isolated` 那个问题在 `single` 下从未被
|
|
30
|
+
问过,所以按「未确认」处理(唯一不会静默放行跨客户约束的方向)。
|
|
31
|
+
- ⛔ **永不做成启动期告警**(#4884 纪律)。boot 时的 rehydrate 不评估此门;门够不到
|
|
32
|
+
的两类存量 —— 门禁上线前的安装、装后姿态变更的环境 —— 由 `os doctor` 与
|
|
33
|
+
`os migrate plan` 的 advisory 形态覆盖。
|
|
34
|
+
|
|
35
|
+
判定里有三条是承重的,别「简化」掉:声明索引上的裸 `unique: true` **算**(D1 说它
|
|
36
|
+
就是 `'global'` 的位置式拼写,排除它等于让整个 17.x 可以靠拼写绕过);字段级
|
|
37
|
+
`true` **不算**(它是 `'organization'`,永久合法);`sys_`/`base_` 对象**不算**
|
|
38
|
+
(S5 那批引擎幂等键天然就是平台级的,每次安装都问一遍就是 #4884 的误报类)。
|
|
39
|
+
|
|
40
|
+
CLI: `os package install` 新增 `--confirm-global-uniques`,并把 409 渲染成可读的
|
|
41
|
+
逐条清单而不是一句 "Install failed (409)"。
|
|
42
|
+
|
|
43
|
+
**D5c —— 遗留手写组织复合索引的 advisory。** 新规则
|
|
44
|
+
`unique/legacy-organization-composite`:声明的唯一索引自己列出了组织列
|
|
45
|
+
(`{ fields: ['name','organization_id'], unique: true }`)—— 这是词汇出现之前手写
|
|
46
|
+
per-organization 的写法。它读起来像「每组织唯一」,物化出来却是普通复合索引,而
|
|
47
|
+
SQL UNIQUE 是 NULL-distinct 的:组织列为 NULL 的行上它**什么都不约束**(#5030),
|
|
48
|
+
在单组织部署上那就是每一行。改写成 `unique: 'organization'`(`fields` 原样保留,
|
|
49
|
+
driver 会把已列出的组织列**就地**变成 NULL-safe 形式)正是补上这个洞的动作。
|
|
50
|
+
**永远只是 advisory,永远不自动修**:老拼写永久合法、零强制 drift,而 opt-in 是
|
|
51
|
+
真实的物理收紧,要走 D4 的 `recreate_index` + 重复预检。
|
|
52
|
+
|
|
53
|
+
**D6 —— 成文契约扫荡。** `content/docs/data-modeling/indexing.mdx` 的
|
|
54
|
+
§Two ways to say "unique" 全节按新词汇重写(含 `os:check` 代码块);
|
|
55
|
+
`content/docs/protocol/objectql/schema.mdx` 的 §Uniqueness and tenancy 重写为
|
|
56
|
+
§Uniqueness and scope —— 其中那句「单租户部署不受影响,租户列是常量,复合索引
|
|
57
|
+
退化为单列索引」是 #5030 **证伪过的原话**,现已替换为 D3 的 NULL-safe 事实;
|
|
58
|
+
`content/docs/deployment/cli.mdx` 的 `replace_unique_index` / `recreate_index`
|
|
59
|
+
条目补上 NULL-safe 形状与重复预检;`content/docs/references/**` 经
|
|
60
|
+
`gen:schema && gen:docs` 再生成,未手改。
|
|
61
|
+
|
|
62
|
+
按 ADR-0120 Resolved #2 的非规范性引导(官方示例/脚手架/生成器在新代码中输出
|
|
63
|
+
显式拼写),`skills/objectstack-data/**` 的索引与校验规则整体扫过:声明索引一律
|
|
64
|
+
说清 scope,并新增一节完整讲 `'organization'` 的 NULL-safe 语义与「永远不写姿态」。
|
|
65
|
+
顺带修掉那里长期使用的 `tenant_id` —— 平台的列叫 `organization_id`。
|
|
66
|
+
`examples/**`、`create-objectstack` 模板与 `os generate` 经核查**根本没有声明任何
|
|
67
|
+
唯一约束**,故无可扫;这是核查结论,不是遗漏。
|
|
68
|
+
|
|
69
|
+
**三姿态 conformance(ADR §Acceptance tests)。** 同一个 fixture app 在
|
|
70
|
+
`single | group | isolated` 三姿态下启动,逐 S 行用**真实的违规插入**断言 enforcement
|
|
71
|
+
(S1/S2/S3/S4/S5/S6/S7/S8/S9/S11/S12),并逐姿态捕获物化出的索引键,断言三者
|
|
72
|
+
**逐字节相同** —— 「没有任何索引形状读姿态」这句话一旦有两者不同就是假的。相同性
|
|
73
|
+
断言配了一条正向断言(对着期望的键形状),这样「三次都什么都没建」不会读成「一致」。
|
|
74
|
+
外加 ADR 只要的那一条 transition smoke:在 `single` 下建库、`isolated` 下重新打开,
|
|
75
|
+
drift op 为零。
|
|
76
|
+
|
|
77
|
+
对既有部署的影响:除新增的安装期确认外,本次不改变任何已有物化行为。字段级
|
|
78
|
+
`unique: true` 一如既往合法。
|
|
79
|
+
|
|
80
|
+
- ddc2527: fix(approvals): the ADR-0044 revise window is a service-owned node type, not a bare `wait` (#3823)
|
|
81
|
+
|
|
82
|
+
#3801 gated `POST /api/v1/automation/:name/runs/:runId/resume` on the **node type**
|
|
83
|
+
that produced the suspension: an `approval` pause declares
|
|
84
|
+
`resumeAuthority: 'service'`, so it continues only through `ApprovalService`.
|
|
85
|
+
ADR-0044's **revise window** was the same trust boundary in a shape that key
|
|
86
|
+
could not see. Send-back parked the run on an ordinary `wait` node the flow
|
|
87
|
+
author placed — correctly `resumeAuthority: 'any'`, because a signal wait is
|
|
88
|
+
_meant_ to be resumable by an external producer — and `ApprovalService.resubmit`
|
|
89
|
+
was the only thing that checked anything about continuing it.
|
|
90
|
+
|
|
91
|
+
Demonstrated (not reasoned) against the real engine: a raw `resume(runId)` with
|
|
92
|
+
an **empty body**, from any caller, walked the `resubmit` back-edge into the
|
|
93
|
+
approval node and opened round N+1 with **no submitter check and no `resubmit`
|
|
94
|
+
audit row** (`['submit','revise']` — no third row, ever). Worse, when another
|
|
95
|
+
request was already pending on the record — the exact case `resubmit` refuses
|
|
96
|
+
with `DUPLICATE_REQUEST` _specifically to keep the run alive_ — the raw resume
|
|
97
|
+
went around that guard: the approval node's re-entry failed **after** the engine
|
|
98
|
+
consumed the suspension, and the run was **permanently destroyed** with its
|
|
99
|
+
round-N request stuck `returned` and no resubmit able to reach it.
|
|
100
|
+
|
|
101
|
+
The revise pause is therefore its own node type:
|
|
102
|
+
|
|
103
|
+
- **`approval_revise`** (`APPROVAL_REVISE_NODE_TYPE`), registered by
|
|
104
|
+
`@objectstack/plugin-approvals` alongside the `approval` node, declaring
|
|
105
|
+
`resumeAuthority: 'service'`. It stays a first-class box on the canvas, in the
|
|
106
|
+
run log and in the suspended-run store — only the _reuse_ of `wait` was wrong.
|
|
107
|
+
It takes **no config**: the window ends on the submitter's explicit resubmit,
|
|
108
|
+
never on a signal or timer. The `resumeAuthority` gate itself is unchanged.
|
|
109
|
+
- `sendBack` refuses a `revise` edge whose target is not an `approval_revise`
|
|
110
|
+
node, **before any mutation** (like the existing missing-`revise`-edge check),
|
|
111
|
+
so no run can be parked in a window something else can advance.
|
|
112
|
+
- New gating lint `flow-approval-revise-target-not-service-owned`
|
|
113
|
+
(severity `error`, on `os build` / `os validate` / `os lint` and the runtime
|
|
114
|
+
metadata publish gate) rejects the old shape at authoring time.
|
|
115
|
+
|
|
116
|
+
**Upgrading a flow authored against the original ADR-0044 D3.** One token:
|
|
117
|
+
|
|
118
|
+
- **FROM:** `{ id: 'wait_revision', type: 'wait', waitEventConfig: { eventType: 'signal', … } }`
|
|
119
|
+
- **TO:** `{ id: 'wait_revision', type: 'approval_revise' }` — drop
|
|
120
|
+
`waitEventConfig` / any `config`; the window has no event to wait on.
|
|
121
|
+
|
|
122
|
+
Until you do, such a flow keeps registering and running and its approvals stay
|
|
123
|
+
decidable (`approve` / `reject` / `recall` / `reassign` are untouched), but
|
|
124
|
+
**send-back is refused** with a message naming the node and this fix, and
|
|
125
|
+
re-publishing it reports the lint error. A run _already parked_ in a legacy
|
|
126
|
+
revise window keeps its recorded node type (a republish never re-types a live
|
|
127
|
+
pause) and is drained by `resubmit` or `recall` as usual.
|
|
128
|
+
|
|
129
|
+
ADR-0044's 2026-07-28 amendment records the reversal of its D3 and of its
|
|
130
|
+
`Alternatives` rejection of a service-owned revise pause, with the evidence
|
|
131
|
+
above; the implementation section there records what shipped, why the approval
|
|
132
|
+
node does not re-suspend itself instead, and why no ADR-0087 conversion was
|
|
133
|
+
added for the old shape.
|
|
134
|
+
|
|
135
|
+
- 0161c7f: feat(spec,lint): declare the chart segment drill — `ChartDrillDownSchema`, on the react tier where it is actually read (#5022)
|
|
136
|
+
|
|
137
|
+
`drillDown` has driven a real capability since long before this release: click a
|
|
138
|
+
bar or a slice on an `<ObjectChart>` and objectui opens the underlying records,
|
|
139
|
+
filtered by the clicked category, in a drawer. The protocol declared it
|
|
140
|
+
**nowhere**. objectui read it as `(schema as any).drillDown`, so every key inside
|
|
141
|
+
it — right, wrong, or misspelled — reached the renderer unchecked, and a typo was
|
|
142
|
+
simply ignored at click time. This is Prime Directive #10 inverted: not declared
|
|
143
|
+
without being delivered, but delivered without ever being declared.
|
|
144
|
+
|
|
145
|
+
It is declared now, as `ChartDrillDownSchema`, and it is **additive** — nothing
|
|
146
|
+
that parsed before stops parsing.
|
|
147
|
+
|
|
148
|
+
## What you can write
|
|
149
|
+
|
|
150
|
+
`drillDown` is a prop on the react-tier `<ObjectChart>` block:
|
|
151
|
+
|
|
152
|
+
```jsx
|
|
153
|
+
<ObjectChart
|
|
154
|
+
objectName="opportunity"
|
|
155
|
+
aggregate={{ function: "sum", field: "amount", groupBy: "stage" }}
|
|
156
|
+
drillDown={{ columns: ["name", "amount"], maxRows: 50 }}
|
|
157
|
+
/>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| key | type | meaning |
|
|
161
|
+
| --------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
162
|
+
| `enabled` | `boolean` | Only needed to force the drill OFF — the block being present already means on, so `drillDown={{}}` enables it |
|
|
163
|
+
| `filter` | `Record<string, unknown>` | Filter for the drilled list; values support `${event.*}`. Omit it and the filter is derived from `aggregate.groupBy` equal to the clicked category |
|
|
164
|
+
| `title` | `string` | Drawer/dialog heading; supports `${event.*}` |
|
|
165
|
+
| `target` | `'drawer' \| 'dialog'` | In-place side sheet (default), or a centered modal when the chart is already inside a drawer |
|
|
166
|
+
| `columns` | `string[]` | Column whitelist for the drilled list |
|
|
167
|
+
| `maxRows` | `number` | Rows per page in the drilled list |
|
|
168
|
+
|
|
169
|
+
Every one of those six is a key objectui's `ObjectChart` was measured to read.
|
|
170
|
+
The renderer's own drill type is wider — it is shared with the table / pivot /
|
|
171
|
+
metric widgets — and the extra keys are **deliberately not declared**, because a
|
|
172
|
+
chart reads none of them:
|
|
173
|
+
|
|
174
|
+
- **`mode`** (`'filter'`/`'record'`) is a table/pivot/metric key. A chart segment
|
|
175
|
+
is always an aggregate, so there is nothing to discriminate.
|
|
176
|
+
- **`report`** (drill into a report instead of a record list) is a metric/pivot
|
|
177
|
+
capability.
|
|
178
|
+
- **`view`** and **`sort`** are read by _no_ renderer at all (objectui#3354).
|
|
179
|
+
- **`target: 'navigate'`** is implemented for the other widgets but not for a
|
|
180
|
+
chart, which falls back to the drawer.
|
|
181
|
+
|
|
182
|
+
Writing any of them is now a loud rejection that says which surface owns it,
|
|
183
|
+
rather than a value that silently does nothing.
|
|
184
|
+
|
|
185
|
+
## Where it is NOT declared, and why that is deliberate
|
|
186
|
+
|
|
187
|
+
**Not on `ChartConfigSchema`, and not a dashboard widget key.** A dashboard
|
|
188
|
+
widget has no per-widget drill configuration, by design: an ADR-0021
|
|
189
|
+
dataset-bound widget drills through the semantic layer, deriving the target
|
|
190
|
+
object and filter from the dataset row that was clicked. That is what
|
|
191
|
+
`content/docs/ui/dashboards.mdx` has said all along, and it is what the renderer
|
|
192
|
+
does — `DashboardRenderer` never reads `chartConfig`, and `DatasetWidget`
|
|
193
|
+
forwards exactly one key out of it (`showLegend`). Declaring the drill there
|
|
194
|
+
would have produced authorable metadata that parses clean and never reaches a
|
|
195
|
+
renderer — the failure this campaign removes elsewhere.
|
|
196
|
+
|
|
197
|
+
So the three places an author might reach for it now answer instead of shrugging:
|
|
198
|
+
|
|
199
|
+
- `widget.chartConfig.drillDown` → rejected, pointing at the react-tier prop.
|
|
200
|
+
- `widget.drillDown` / `widget.drilldown` → rejected, explaining that dashboard
|
|
201
|
+
drill-through is **automatic**, and naming both configurable drills.
|
|
202
|
+
- `report.drillDown` → rejected, pointing back at the chart prop.
|
|
203
|
+
|
|
204
|
+
## `drillDown` is not `drilldown`
|
|
205
|
+
|
|
206
|
+
Two capabilities, one letter apart, and they are now disambiguated in both
|
|
207
|
+
directions at the schema gate:
|
|
208
|
+
|
|
209
|
+
| | `drillDown` | `drilldown` |
|
|
210
|
+
| -------- | -------------------------- | ----------------------------------------------- |
|
|
211
|
+
| spelling | camelCase | all lowercase |
|
|
212
|
+
| type | configuration object | boolean |
|
|
213
|
+
| surface | react `<ObjectChart>` prop | `ReportSchema` key (ADR-0021 D2, on by default) |
|
|
214
|
+
|
|
215
|
+
Edit distance alone gets this wrong — the two spellings are a distance of 1, so a
|
|
216
|
+
plain "did you mean" would happily send an author writing `drillDown` on a report
|
|
217
|
+
to `drilldown`, where their config object then fails a second time as a boolean.
|
|
218
|
+
Both gates name the **type** difference, not just the spelling.
|
|
219
|
+
|
|
220
|
+
## Enforced, not just declared
|
|
221
|
+
|
|
222
|
+
`@objectstack/lint`'s react-page publish gate now **parses** the schema
|
|
223
|
+
(`react-chart-drilldown-invalid`) against a static `drillDown={{…}}` literal,
|
|
224
|
+
rather than re-deriving the rules. Unknown keys, the wrong `target`, and the
|
|
225
|
+
near-key spelling all fail the build with the schema's own prescription. A value
|
|
226
|
+
assembled from React state is skipped, unchanged: an unresolvable binding is not
|
|
227
|
+
a wrong one (ADR-0072 D1).
|
|
228
|
+
|
|
229
|
+
- eaaf03c: refactor(spec,lint)!: retire the dashboard widget action trio + `aria` — and the build gate that enforced a button nobody renders (#5010, ADR-0049)
|
|
230
|
+
|
|
231
|
+
`DashboardWidgetSchema` let an author declare a per-widget action **button**
|
|
232
|
+
(`actionUrl` / `actionType` / `actionIcon`) and per-widget ARIA attributes
|
|
233
|
+
(`aria`). None of the four reached a renderer. Re-measured 2026-08-04 across both
|
|
234
|
+
repos on a closed call graph:
|
|
235
|
+
|
|
236
|
+
- **the action trio** — all 14 `actionUrl` reads in objectui's
|
|
237
|
+
`DashboardRenderer.tsx` are scoped to `schema.header.actions[]`, which is
|
|
238
|
+
`DashboardHeaderAction`, a _different_ schema. Nothing anywhere reads
|
|
239
|
+
`widget.actionUrl`. `actionIcon` is the starkest: zero references in either
|
|
240
|
+
repo outside its own declaration — not even the lint looked at it.
|
|
241
|
+
- **`aria`** — no consumer of `widget.aria` anywhere. The `aria-*` attributes in
|
|
242
|
+
`DashboardRenderer` / `DatasetWidget` are the renderer's own DOM attributes,
|
|
243
|
+
and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a
|
|
244
|
+
**view**'s. This is the dashboard-level `aria` that #3896 removed, one level
|
|
245
|
+
down — an accessibility guarantee an author could declare and nothing honoured.
|
|
246
|
+
|
|
247
|
+
These four survived the #3896 sweep for the same reason `widgets[].responsive`
|
|
248
|
+
did, and it is not "we looked and they were live": the liveness ledger declared
|
|
249
|
+
no `children` on `dashboard.widgets`, so **no widget-level key had ever been
|
|
250
|
+
classified**. #4956 fixed that instrument and gave all 22 keys their first per-key
|
|
251
|
+
verdicts; this change acts on four of the six it found dead.
|
|
252
|
+
|
|
253
|
+
## The second-order cost this settles
|
|
254
|
+
|
|
255
|
+
`packages/lint`'s `validate-dashboard-action-refs` enforced **ERROR-severity**
|
|
256
|
+
reference integrity on `widgets[].actionUrl` — a dangling target failed the
|
|
257
|
+
build. Its docblock called the key _"the per-widget button"_ and claimed to
|
|
258
|
+
mirror the objectui runtime dispatch. It did not, because that button does not
|
|
259
|
+
exist. So an author could be blocked from shipping because a control that cannot
|
|
260
|
+
render pointed at an action that also did not.
|
|
261
|
+
|
|
262
|
+
A rule written to delete false affordances was sustaining one. That is why the
|
|
263
|
+
keys were retired rather than the check merely relaxed: the widget branch is
|
|
264
|
+
deleted, with a pin test asserting it stays silent and a second pin proving
|
|
265
|
+
header actions are still checked in the same stack.
|
|
266
|
+
|
|
267
|
+
FROM → TO:
|
|
268
|
+
|
|
269
|
+
| Removed | Replacement |
|
|
270
|
+
| :------------------------------- | :----------------------------------------------------------------------------------------------------- |
|
|
271
|
+
| `dashboard.widgets[].actionUrl` | `dashboard.header.actions[].actionUrl` |
|
|
272
|
+
| `dashboard.widgets[].actionType` | `dashboard.header.actions[].actionType` |
|
|
273
|
+
| `dashboard.widgets[].actionIcon` | `dashboard.header.actions[].icon` (the header spelling) |
|
|
274
|
+
| `dashboard.widgets[].aria` | **none** — delete it; author `title`/`description`, which the renderer really does label the card with |
|
|
275
|
+
|
|
276
|
+
For a per-**row** affordance, reach for a dataset-bound `table`/`pivot` widget:
|
|
277
|
+
its rows are clickable and drill through the semantic layer already (no
|
|
278
|
+
per-widget drill config exists, by design — #5022).
|
|
279
|
+
|
|
280
|
+
**The `AriaProps` shape is NOT removed — only this embed.** `AriaPropsSchema` /
|
|
281
|
+
`AriaProps` stay exported and stay live on `app.aria` and
|
|
282
|
+
`page.components[].aria`. Nothing importing the shape breaks.
|
|
283
|
+
|
|
284
|
+
The retirement kit:
|
|
285
|
+
|
|
286
|
+
- **Tombstones.** `retiredKey()` on all four, matching `responsive` in this same
|
|
287
|
+
schema. `DashboardWidgetSchema` _is_ `.strict()`, so a plain delete would still
|
|
288
|
+
be loud — but only as a generic "unrecognized key". The tombstone keeps the key
|
|
289
|
+
declared so the rejection carries the **prescription**, and types it `never` so
|
|
290
|
+
authoring it fails `tsc` first. Pins assert the message _is_ the prescription
|
|
291
|
+
and is _not_ `Unrecognized key`. The action trio shares one prescription that
|
|
292
|
+
names all three, so an author who deletes the one key they were told about does
|
|
293
|
+
not hit the same error twice more.
|
|
294
|
+
- **ADR-0087 D2 conversion + D3 chain step**
|
|
295
|
+
(`dashboard-widget-action-aria-removed`, `retiredFromLoadPath`):
|
|
296
|
+
`os migrate meta --from 16` strips the four from author sources, and stored
|
|
297
|
+
dashboards replay clean instead of meeting a tombstone at load. Lossless
|
|
298
|
+
deletes — none of the keys had an effect to lose. Its own entry rather than
|
|
299
|
+
more keys on `dashboard-inert-keys-removed`, whose identity is the #3896 sweep.
|
|
300
|
+
- **Liveness rows stay** (`status: dead`, `verifiedAt`, a REMOVED note) because
|
|
301
|
+
a tombstone keeps the key in the walked shape — the `rls.priority` precedent.
|
|
302
|
+
`authorWarn`/`authorHint` are dropped from all four: the parse owns them now.
|
|
303
|
+
- Baselines moved at KEY level only, as the shape's survival implies:
|
|
304
|
+
`authorable-surface.json` gains four `… [RETIRED]` lines;
|
|
305
|
+
`json-schema.manifest.json`, `api-surface.json` and
|
|
306
|
+
`api-surface-signatures.json` are unchanged by construction — no def stopped
|
|
307
|
+
being emitted and no export was removed.
|
|
308
|
+
|
|
309
|
+
No runtime behaviour changes — that impossibility is the reason for the removal.
|
|
310
|
+
The one behaviour that _does_ change is a build that used to fail and now does
|
|
311
|
+
not.
|
|
312
|
+
|
|
313
|
+
## Not in this change
|
|
314
|
+
|
|
315
|
+
`widgets[].colorVariant`, the fifth dead key #5010 lists, is **deliberately
|
|
316
|
+
untouched**. The rewrite target its triage assumed — `options.colorVariant` —
|
|
317
|
+
measured dead as well: `options` only reaches a renderer through the inline
|
|
318
|
+
`componentSchema` path, and `dataset` is _required_ on this schema, so every
|
|
319
|
+
spec-authorable widget is dataset-bound and renders through `DatasetWidget`,
|
|
320
|
+
which has no colour affordance at all. Moving the key would relocate 16 authored
|
|
321
|
+
sites (7 in `platform-objects`, 9 in `app-showcase`) from one dead slot to
|
|
322
|
+
another and mint a second inert key. Returned for adjudication.
|
|
323
|
+
|
|
324
|
+
- 06ffad3: fix(lint): 字段公式校验首次对 spec 合法元数据生效 —— `f.formula` 收敛为 `f.expression` (#5026)
|
|
325
|
+
|
|
326
|
+
`validate-expressions.ts` 的字段公式校验(`validateStackExpressions` 里的
|
|
327
|
+
field-formula pass)一直读 `f.formula`。`FieldSchema` 声明的是 `expression`,而
|
|
328
|
+
`formula` 恰是 `field.zod.ts:333` **按名拒绝**的别名之一
|
|
329
|
+
(`aliases: { formula: 'expression', calculation: 'expression', compute: 'expression' }`)。
|
|
330
|
+
该规则以 `input: 'parsed'` 注册,compile/build/validate 路径上看到的是
|
|
331
|
+
`ObjectStackSchema` 的解析产物,所以 `f.formula` 恒为 `undefined` ——
|
|
332
|
+
**整段检查对任何 spec 合法 stack 从未执行过一次**。
|
|
333
|
+
|
|
334
|
+
这不是删死代码,是**启用一条从未跑过的检查**。字段公式从此真正受
|
|
335
|
+
ADR-0032 §1a/1b 的三条判决管辖:CEL 语法、`record.<field>` 字段存在性、
|
|
336
|
+
以及 #1928 的裸引用 / 类型健全性。对 AI 生成的元数据这一条最要紧 ——
|
|
337
|
+
`amount * probability`(而不是 `record.amount * record.probability`)正是公式槽位
|
|
338
|
+
最常见的错法,它在 CEL 里静默求值为 null,过去没有任何门拦得住。
|
|
339
|
+
|
|
340
|
+
**覆盖面扩大,但对现有元数据零新红。** 激活后在仓库全部真实元数据上实测过:
|
|
341
|
+
`examples/app-showcase`(3 个 `expression` 槽)、`examples/app-crm`(5 个)、
|
|
342
|
+
`examples/app-todo`(0 个)全部 `ObjectStackSchema` 解析通过,新增判决 0 条;
|
|
343
|
+
`platform-objects`、`plugin-security` 的 default-permission-sets 不含公式字段;
|
|
344
|
+
`skills/` 里的公式样例全部已是 canonical 拼法。
|
|
345
|
+
|
|
346
|
+
**Authoring impact.** 之前拼 `formula:` 的字段本来就无法解析,schema 会按名拒绝
|
|
347
|
+
并给出 `Did you mean \`formula\` → \`expression\`?`——该行为不变,本规则不再对同一个键
|
|
348
|
+
给出第二套说法。诊断定位串同步改名以免继续传播错拼法:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
FROM object 'X' · field 'Y' formula
|
|
352
|
+
TO object 'X' · field 'Y' expression
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`validate-null-guards.ts` 的 surface ledger 相应把该行从 `Field.formula` 正名为
|
|
356
|
+
field `expression`(`Field.formula({ expression: … })` 写入的槽)。null-guard 判决
|
|
357
|
+
**仍然**排除该 surface(公式是 `value` 角色、天然可空,`guard ? value : null` 是祝福
|
|
358
|
+
写法),排除的只是 null-guard 这一条,语法 / 字段存在性 / 裸引用判决从此生效。
|
|
359
|
+
|
|
360
|
+
`validate-expressions.test.ts` 的 `TRACKED_UNDECLARED_READS` 记账随之清空 —— 这
|
|
361
|
+
份"只缩不长"的清单现在是零条,规则读的每一个键都是 spec 声明的键。
|
|
362
|
+
|
|
363
|
+
- 123067c: fix(cli): the i18n walker collects `objects.<o>._sections` — section headings are gated and scaffolded like every other label (#5405)
|
|
364
|
+
|
|
365
|
+
An object's SECTION headings were the one declared, resolved, rendered
|
|
366
|
+
translation surface the shared i18n walker had no kind for.
|
|
367
|
+
`ExpectedEntry['source']` listed `object | field | option | view | action |
|
|
368
|
+
globalAction | app | navigation | dashboard | widget | page` plus two
|
|
369
|
+
`metadataForm*` kinds — and those two cover **Studio metadata forms**
|
|
370
|
+
(`metadataForms.<type>.sections.*`, hidden behind `--include-platform`), not app
|
|
371
|
+
objects. So `objects.<o>._sections.<s>.label` was structurally unreachable in
|
|
372
|
+
both directions: `os i18n extract` never scaffolded a heading, and
|
|
373
|
+
`os lint` could not report one missing.
|
|
374
|
+
|
|
375
|
+
The surface itself was never in doubt. `ObjectTranslationDataSchema` declares
|
|
376
|
+
`_sections` (with `sections` as an authoring alias), and `@object-ui/i18n`'s
|
|
377
|
+
`sectionLabel` resolves it for `record:details`, for `ObjectForm`/`ModalForm`
|
|
378
|
+
and for the field-group designer. Only the walker disagreed — which is exactly
|
|
379
|
+
the drift `collectExpectedEntries` was consolidated to prevent (#3370).
|
|
380
|
+
|
|
381
|
+
Measured downstream before this landed: 85 sections across 15 objects, **2 of
|
|
382
|
+
85** translated in `ja-JP` and in `es-ES` — English headings on essentially
|
|
383
|
+
every record page and form — with `objectstack lint` reporting **zero** i18n
|
|
384
|
+
warnings for both locales.
|
|
385
|
+
|
|
386
|
+
**What is collected.** A `section` kind, from the two independent surfaces that
|
|
387
|
+
both resolve to the same key — a heading is expected if _either_ declares it,
|
|
388
|
+
and one heading is one expected key however many declare it:
|
|
389
|
+
|
|
390
|
+
- **`fieldGroups` × field `group`** — the fields decide which sections exist and
|
|
391
|
+
`fieldGroups[].label` supplies the source text. Membership is read through
|
|
392
|
+
`deriveFieldGroupLayout` (ADR-0085 §5), the same shared derivation the
|
|
393
|
+
renderers consume, so a group nothing visible references — or a `group:` no
|
|
394
|
+
`fieldGroups` entry declares — produces no heading and therefore no expected
|
|
395
|
+
key. The trailing ungrouped bucket renders without chrome and is skipped.
|
|
396
|
+
- **A named `sections[]`** on a form view (including a view container's default
|
|
397
|
+
`form`) or inside a record page's component tree.
|
|
398
|
+
|
|
399
|
+
A section with no `name` is skipped: every renderer guards the lookup on it
|
|
400
|
+
(`s?.name ? sectionLabel(…) : s?.label`), so it is untranslatable by
|
|
401
|
+
construction and demanding a bundle entry for it would be noise. A group that
|
|
402
|
+
declares no `label` still yields a scaffold key seeded from its own name, but no
|
|
403
|
+
coverage finding — nobody authored that text.
|
|
404
|
+
|
|
405
|
+
**What you get.** `os lint` gains an `i18n/missing-section` category — user
|
|
406
|
+
metadata, so it is reported without `--include-platform` — and `os i18n
|
|
407
|
+
extract` scaffolds the headings for free, because the gate and the extractor
|
|
408
|
+
read the one walker. A project that declares no locales still reports nothing;
|
|
409
|
+
the gate stays opt-in.
|
|
410
|
+
|
|
411
|
+
**`@objectstack/lint`** now exports its shared page traversal
|
|
412
|
+
(`walkPageComponents`, `isSourceAuthoredPage`, `WalkedComponent`) so the CLI
|
|
413
|
+
consumes it instead of growing a private copy — that walk exists precisely
|
|
414
|
+
because duplicating it produced a dead rule once already (#3583). Reusing it is
|
|
415
|
+
also what makes the page half correct rather than merely present: it reaches
|
|
416
|
+
`slots.<slot>` and the untyped nesting a record page really uses
|
|
417
|
+
(`page:tabs` → `properties.items[].children[]` → `record:details`), skips
|
|
418
|
+
source-authored pages whose `regions` are a derived cache, and resolves each
|
|
419
|
+
component's OWN binding (`dataSource.object` → `properties.object` → the page's
|
|
420
|
+
`object`) — so a re-bound `record:details` keys its headings under the object it
|
|
421
|
+
actually shows.
|
|
422
|
+
|
|
423
|
+
- 5582e18: never-fire 族三条 lint 规则 warning → error:声明了触发器却确定不会运行的 flow 现在被拒收
|
|
424
|
+
|
|
425
|
+
`validateFlowTriggerReadiness` 里的规则此前一律是 warning。族内复审(#5762)后按**一个**标准重新定级 ——
|
|
426
|
+
「仅凭这份 stack 是否已经足够断定这个 flow 不会运行」—— 三条规则答「是」,升为 `error`:
|
|
427
|
+
|
|
428
|
+
- **`flow-time-relative-descriptor-invalid`**(#5496)—— `config.timeRelative` 描述符
|
|
429
|
+
`TimeRelativeTriggerSchema` 判不过。判它的正是 trigger 在 bind 时 `safeParse` 的同一个 schema,
|
|
430
|
+
所以 schema 拒的描述符在运行时同样被拒:sweep 永不安装,flow 声明了 time-relative 触发器却永不运行。
|
|
431
|
+
- **`flow-time-relative-descriptor-unroutable`**(#5647)—— `config.timeRelative` 不是对象
|
|
432
|
+
(`timeRelative: 'daily'` 是典型)。判据就是引擎自己的路由谓词 `typeof … === 'object'`,
|
|
433
|
+
所以这个值不会被任何部署路由到 time-relative trigger —— 全族里最硬的判定,也是唯一连
|
|
434
|
+
bind 时那一行 warn 都没有的一条。
|
|
435
|
+
- **`flow-trigger-unknown-event`**(#3427/#3457/#3481)—— `record-*` token 落在文法之外
|
|
436
|
+
(`record-after-updated` 拼错、`record-change` 缺相位、数组形式)。引擎用硬编码前缀把任何
|
|
437
|
+
`record-` 开头的 token 路由给 record-change trigger(不查注册表,所以装任何包都无法声明新
|
|
438
|
+
`record-*` token),该 trigger 再用 `triggerTypeToHookEvents` 的封闭文法映射 —— 文法外就是零
|
|
439
|
+
hook event,即绑不上任何东西。
|
|
440
|
+
|
|
441
|
+
**两条对照规则维持 warning,这正是本次定级不是「整个文件都升 error」的原因:**
|
|
442
|
+
|
|
443
|
+
- `flow-trigger-unknown-object` —— 本 stack 没定义的对象名**可能由另一个已安装包提供**,
|
|
444
|
+
规则看不见那个包的对象。免责是真的,所以继续 advisory(它自己的 hint 就这么写)。
|
|
445
|
+
- `flow-draft-status-ambiguous` —— draft flow **确实会**触发,这是意图歧义而非死 flow。
|
|
446
|
+
|
|
447
|
+
## 影响面:P1 运行时发布门(`surfaces: CLI_AND_RUNTIME`)
|
|
448
|
+
|
|
449
|
+
注册表 tier 同步 `advisory` → `gating`。除 `os validate` / `os build` / `os lint` 之外,本规则也跑在
|
|
450
|
+
元数据写入路径的 publish 门上,`error` 会让 `state: 'active'` 的写入被拒(422)。**边界值得写清,因为
|
|
451
|
+
直觉猜错的方向恰好对租户有利**:该门交给规则的快照里 `flows` 只装**正在写入的那一个** item
|
|
452
|
+
(`runtime-gate.ts`:`candidate = { objects, [stackKey]: [item] }`),并且会减掉 baseline 已产出的
|
|
453
|
+
finding。所以:
|
|
454
|
+
|
|
455
|
+
- 发布 flow A **不会**因为已存储的 flow B 是死 flow 而被拒;租户既有的死 flow 继续被读取和服务
|
|
456
|
+
(ADR-0087 读路径不对称);
|
|
457
|
+
- 被拒的是**死 flow 自己的发布**(含再次发布),以及 CLI 侧 —— stack 里含死 flow 的包
|
|
458
|
+
`os validate` / `os build` / `os lint` 转红;
|
|
459
|
+
- draft 保存从不过门(#4463 D1),只有发布才过。
|
|
460
|
+
|
|
461
|
+
## 迁移
|
|
462
|
+
|
|
463
|
+
修死 flow,不要降级规则:
|
|
464
|
+
|
|
465
|
+
- 描述符判不过 → 按 finding 里 `TimeRelativeTriggerSchema` 的原文改(它会点名该写的键);
|
|
466
|
+
描述符要 `{ object, dateField, 且 withinDays | offsetDays 恰好其一 }`。
|
|
467
|
+
- `timeRelative` 写成了 `'daily'` 这类节奏值 → 节奏是**同级兄弟键** `config.schedule`
|
|
468
|
+
(默认每日 08:00 UTC,通常可省),`timeRelative` 只描述扫哪些记录。
|
|
469
|
+
- `record-*` token 落在文法外 → 用 `record-{before,after}-{create,insert,update,delete,write}`;
|
|
470
|
+
「创建或更新」用 `record-after-write` 一条 flow 覆盖(#3427);多事件数组仍未支持(#3457),
|
|
471
|
+
按事件各写一条 flow。
|
|
472
|
+
|
|
473
|
+
仓内三个示例 app(showcase / CRM / todo)`os validate` 升级前后输出逐字一致、均通过,
|
|
474
|
+
本次升级不需要修改任何示例。
|
|
475
|
+
|
|
476
|
+
- 2f1e2a5: feat(lint): null-guard 闸门覆盖 `requiredWhen`,其余各面按"绑定是否全量"逐一定案 (#4811)
|
|
477
|
+
|
|
478
|
+
#4763 的 null-guard 闸门只接了两面(对象校验规则、生命周期 hook `condition`),
|
|
479
|
+
其余各面留作"待定"。本次把"待定"收敛成一条**可判定的判据**,并按它逐面定案 ——
|
|
480
|
+
一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。
|
|
481
|
+
|
|
482
|
+
## 判据:记录绑定是否对已声明字段**全量**
|
|
483
|
+
|
|
484
|
+
这不是口味问题,也不是"这个谓词是不是 CEL"。实测 `@marcbachmann/cel-js`,两种绑定
|
|
485
|
+
下的语义**恰好相反**:
|
|
486
|
+
|
|
487
|
+
| 谓词 | 全量绑定 `{a: null}` | 稀疏绑定 `{}` |
|
|
488
|
+
| :-------------------- | :----------------------- | :--------------------- |
|
|
489
|
+
| `has(record.a)` | `true` ← 陷阱 | `false` ← 真守卫 |
|
|
490
|
+
| `record.a < record.b` | FAULT `no such overload` | FAULT `No such key: a` |
|
|
491
|
+
| `record.a != null` | `false` ← **修法有效** | FAULT `No such key: a` |
|
|
492
|
+
|
|
493
|
+
即:全量绑定下 `has()` 恒真而无用、`!= null` 是解药;稀疏绑定下 `has()` 恰恰是正确的
|
|
494
|
+
守卫,而 `!= null` **自身就会 fault**。把闸门指向一个稀疏绑定的面,等于判红正确的元数据、
|
|
495
|
+
并给出一个会把它改坏的"修法" —— 比不覆盖更糟。所以:**只有绑定全量的面才可以接入。**
|
|
496
|
+
|
|
497
|
+
## 纳入:字段 `requiredWhen`
|
|
498
|
+
|
|
499
|
+
议题没有列出这一面,而它恰恰是唯一满足判据的:`evaluateValidationRules` 用与对象校验
|
|
500
|
+
规则**同一个** `materializeDeclaredFields` 合并记录来求值 `requiredWhen`。
|
|
501
|
+
|
|
502
|
+
它也是几个已覆盖面里失败得最安静的一个:`requiredWhen` 谓词 fault 时是 **fail-open** ——
|
|
503
|
+
`rule-validator.ts` 记一行 `failed to evaluate — skipped` 就跳过,字段于是**从未真正必填**,
|
|
504
|
+
写入照常通过。校验规则至少自 #4761 起是 fail-closed 的拒绝。因此报错文案按面区分后果:
|
|
505
|
+
"被跳过、字段从未必填"与"写入被 fail-closed 拒绝"是两个相反的故障,作者需要知道自己
|
|
506
|
+
碰到的是哪一个。
|
|
507
|
+
|
|
508
|
+
## 排除,且各自留下可引用的理由
|
|
509
|
+
|
|
510
|
+
- **action `visible` / `disabled`**:谓词确实走真 CEL(裸串经 `ExpressionInputSchema`
|
|
511
|
+
规范成 `{dialect:'cel'}` 信封,渲染器保留它),fault 也确实 fail-closed —— 陷阱在这一面
|
|
512
|
+
是真的。但绑定是客户端已取到的那条记录(详情读取,或只带列表视图投影列的一行),
|
|
513
|
+
`objectui` 这条路径上不存在任何物化步骤。稀疏绑定下 `!= null` 是错的修法。要覆盖它,
|
|
514
|
+
得先决定是否把该绑定做成全量 —— 那是平台契约改动,不是 lint 改动。
|
|
515
|
+
- **flow / edge `condition`**:议题记的理由(扁平作用域下裸标识符可能是 flow 变量)对本
|
|
516
|
+
模块**不成立** —— 它只解析 `record.<f>` / `previous.<f>`,从不解析裸标识符,而引擎无
|
|
517
|
+
条件绑定这两个根。真正的阻碍还是全量性:`record-change-trigger.ts` 把记录播种为
|
|
518
|
+
`{ ...inputDoc, ...after }`,没有 `materializeDeclaredFields`,所以写入未提及的已声明列
|
|
519
|
+
是**缺键**而非 null,`!= null` 会和它本要守卫的比较一样 fault。
|
|
520
|
+
- **字段 `readonlyWhen`**:与 `requiredWhen` 同一个字段、相反的结论 —— 它由
|
|
521
|
+
`stripReadonlyWhenFields` 求值,那里合并的是 `{ ...previous, ...data }`,从不物化。
|
|
522
|
+
- **`Field.formula`**:按产品判断排除,而非按本判据。formula 是 `value` 角色、天然可空,
|
|
523
|
+
`guard ? value : null` 是被祝福的写法(#3306)。是否强制守卫会改变"作者被允许写什么",
|
|
524
|
+
该由维护者决定,不是一个接线缺口。
|
|
525
|
+
|
|
526
|
+
判据、实测表与逐面台账写在 `validate-null-guards.ts` 的模块注释里,每条排除在它对应的
|
|
527
|
+
调用点也留了注释,并各配一条断言钉住。
|
|
528
|
+
|
|
529
|
+
## 顺带修正:`field '?'`
|
|
530
|
+
|
|
531
|
+
诊断的字段名此前走 `Object.values(fields)`,把**名字键**丢掉了 —— 而名字键正是
|
|
532
|
+
`Field.text({…})` 这种(最常见的)写法产生的形状,于是这类对象上的每条字段级诊断都定位在
|
|
533
|
+
`field '?'`。名字只出现在 `where` 里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。
|
|
534
|
+
|
|
535
|
+
- af96af6: fix(lint): `validateOrgAxisRedLines` reads the sharing-rule keys the spec declares — the ADR-0105 D6 red lines fired on nothing before (#4984)
|
|
536
|
+
|
|
537
|
+
The two ADR-0105 D6 red lines are declared `error` and gate `os validate` /
|
|
538
|
+
`os build` / `os lint`. On the sharing-rule path neither of them could fire.
|
|
539
|
+
|
|
540
|
+
`validateOrgAxisRedLines` read `rule.criteria ?? rule.filter` and
|
|
541
|
+
`rule.sharedTo ?? rule.recipient`. `SharingRuleSchema` is `.strict()` and its
|
|
542
|
+
declared keys are `condition` and `sharedWith`; all four names it was reading
|
|
543
|
+
exist only as **rejected aliases** in `sharingRuleUnknownKeyError`, the
|
|
544
|
+
prescription attached to the refusal message. The rule runs on the post-parse
|
|
545
|
+
stack (`input: 'parsed'`), so for every spec-valid stack those four properties
|
|
546
|
+
were `undefined`, `JSON.stringify(undefined ?? '')` was `'""'`, and the
|
|
547
|
+
`parent_organization_id` test was constantly false.
|
|
548
|
+
|
|
549
|
+
**This is a behaviour change: the rule previously never triggered.** Both red
|
|
550
|
+
lines are now live on the sharing-rule path:
|
|
551
|
+
|
|
552
|
+
| Authored shape | Before | After |
|
|
553
|
+
| :--------------------------------------------------------------------------- | :----- | :------------------------------------------------------------------------ |
|
|
554
|
+
| `condition` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].condition` |
|
|
555
|
+
| `sharedWith` reading `parent_organization_id` | passed | `error` `org-axis-permission-inheritance` at `sharingRules[i].sharedWith` |
|
|
556
|
+
| `sharedWith: { type: 'business_unit' }` on a `tenancy.enabled: false` object | passed | `error` `org-axis-cross-org-bu-grant` at `sharingRules[i].sharedWith` |
|
|
557
|
+
|
|
558
|
+
A stack that ships today keeps building unless it contains one of those three —
|
|
559
|
+
the shapes D6 forbids and the gate was meant to have been refusing all along.
|
|
560
|
+
The rejected aliases are deliberately **not** read: a rule spelling `criteria`
|
|
561
|
+
or `sharedTo` is refused by the schema's own parse with the canonical key
|
|
562
|
+
named, and a consumer must not tolerate what the producer's contract rejects.
|
|
563
|
+
|
|
564
|
+
`condition` is an `ExpressionInput`, so all three of its shapes are scanned —
|
|
565
|
+
the authored bare string, the parsed `{ dialect, source }` envelope, and the
|
|
566
|
+
compiled `{ dialect, ast }` form.
|
|
567
|
+
|
|
568
|
+
The rule's own tests were the reason this survived review: their fixtures used
|
|
569
|
+
the same rejected aliases, so the suite was green while the gate was dead. Every
|
|
570
|
+
sharing-rule fixture now goes through `SharingRuleSchema` before the lint sees
|
|
571
|
+
it, and every object fixture through `ObjectSchema` — a fixture that drifts from
|
|
572
|
+
the spec surface fails at the fixture instead of silently exercising a shape no
|
|
573
|
+
author can write.
|
|
574
|
+
|
|
575
|
+
- 5b8f95b: fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (#4889)
|
|
576
|
+
|
|
577
|
+
`readonlyWhen: P\`parent.status == 'paid'\``— the documented "once the header
|
|
578
|
+
invoice is Paid, its lines are frozen" lock — was enforced **only in the client
|
|
579
|
+
grid**. The server-side strip bound`record`and`previous`and had no`parent`at all, so every parent-scoped predicate faulted, took the fail-open branch, and
|
|
580
|
+
the write landed anyway. On the reference app that meant one`PATCH` rewrote the
|
|
581
|
+
quantity and unit price of a settled invoice's line: HTTP 200, value persisted,
|
|
582
|
+
the grid still drawing the cell read-only. ADR-0057 D10 puts enforcement on the
|
|
583
|
+
server and makes the client courtesy; here only the courtesy layer enforced.
|
|
584
|
+
|
|
585
|
+
**`parent` is now bound on the write path.** For a detail object — one declaring
|
|
586
|
+
exactly one `master_detail` relationship — the engine resolves the master record
|
|
587
|
+
and binds it as `parent` before the strip runs, on both the single-id and the
|
|
588
|
+
bulk (`multi: true`) update paths. A repointing write is judged against the
|
|
589
|
+
master it _lands on_, not the one it leaves. The read is gated on the payload
|
|
590
|
+
actually touching a parent-scoped predicate (decided from the parsed CEL AST, so
|
|
591
|
+
a field named `parent_id` costs nothing), and the bulk path batch-reads the
|
|
592
|
+
distinct headers in one query rather than one per row.
|
|
593
|
+
|
|
594
|
+
**An unbindable scope no longer waives the lock.** A `readonlyWhen` that names a
|
|
595
|
+
root the operation could not bind now resolves to **locked** — the field is
|
|
596
|
+
stripped — instead of "not locked". "The platform could not check this" must not
|
|
597
|
+
mean "allowed" on a field the author declared frozen. This is deliberately the
|
|
598
|
+
narrowest possible carve-out from the fail-open policy the strip has always had:
|
|
599
|
+
a predicate that is merely _broken_ on the record (undeclared key, `null`
|
|
600
|
+
ordering overload, parse error, engine throw) still fails open exactly as
|
|
601
|
+
before, and `requiredWhen` / option `visibleWhen` are untouched. Recorded as an
|
|
602
|
+
addendum to ADR-0058's D5 fail-policy matrix, alongside the same narrowing
|
|
603
|
+
already made for validation predicates (#4649) and hook conditions (#4775).
|
|
604
|
+
|
|
605
|
+
**And the runtime branch is a backstop, not the plan.** `objectstack compile`
|
|
606
|
+
now **rejects** a `parent`-scoped `readonlyWhen` on an object that declares no
|
|
607
|
+
`master_detail` relationship, or two of them (where the metadata does not say
|
|
608
|
+
which one is "the parent" and picking by declaration order would make a
|
|
609
|
+
data-integrity lock depend on field ordering). The common authoring mistake is
|
|
610
|
+
caught where it is cheap to fix, so it never reaches a runtime that has to judge
|
|
611
|
+
it — declared, not guessed.
|
|
612
|
+
|
|
613
|
+
No metadata changes are required: an app whose parent-scoped locks were already
|
|
614
|
+
correct simply starts having them enforced. If you authored one on an object
|
|
615
|
+
with no single master, the build now names it.
|
|
616
|
+
|
|
617
|
+
- 73580e7: feat(lint): the react-page publish gate PARSES `ChartAggregateSchema` instead of re-deriving it (#5020)
|
|
618
|
+
|
|
619
|
+
`<ObjectChart aggregate={{…}}>` is judged at publish time by
|
|
620
|
+
`validate-react-page-props`. That gate used to RE-DERIVE the aggregate's
|
|
621
|
+
declaration: a local `CHART_FUNCTIONS` copy of the function vocabulary and a
|
|
622
|
+
hand-written twin of the schema's count/field refinement. Two implementations of
|
|
623
|
+
one contract, each free to drift — and, because unknown-key handling is a
|
|
624
|
+
property of a **parse** rather than of a list of `if`s, a gate with no
|
|
625
|
+
unknown-key check at all. The rule now calls `ChartAggregateSchema.safeParse()`
|
|
626
|
+
on a statically resolvable literal, exactly as #5022 did for
|
|
627
|
+
`ChartDrillDownSchema` beside it, and both hand-derived copies are deleted:
|
|
628
|
+
`@objectstack/spec` is the single source of the vocabulary and the refinement
|
|
629
|
+
again.
|
|
630
|
+
|
|
631
|
+
**Newly reported (all `error`, all previously silent).** These are shapes the
|
|
632
|
+
schema, the published react-blocks type and objectui's renderer already agreed
|
|
633
|
+
were wrong; the old gate simply could not see them:
|
|
634
|
+
|
|
635
|
+
| authored | before | after |
|
|
636
|
+
| ------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
637
|
+
| `aggregate={{ field: 'total', groupBy: 'status' }}` (no `function`) | accepted | `aggregate.function: Invalid option: expected one of "count"\|"sum"\|"avg"\|"min"\|"max" (nothing is set there)` |
|
|
638
|
+
| `aggregate={{ field: 42, function: 'sum', groupBy: 'status' }}` | accepted | `aggregate.field: Invalid input: expected string, received number` |
|
|
639
|
+
| `aggregate={{ function: 'count', groupBy: 42 }}` | accepted | `aggregate.groupBy: Invalid input (received 42) — no accepted form matched: (1) … (2) …` |
|
|
640
|
+
| `aggregate="count"` / `aggregate={[]}` | accepted | `aggregate must be a configuration object, not string.` |
|
|
641
|
+
|
|
642
|
+
**Re-worded, same verdict.** Two messages now arrive from the schema rather than
|
|
643
|
+
from this rule's own copy. If you match on lint output, update the text:
|
|
644
|
+
|
|
645
|
+
- FROM `aggregate.function "median" is not an aggregation this chart can run.`
|
|
646
|
+
(hint: `Use one of: count, sum, avg, min, max.`)
|
|
647
|
+
TO `aggregate.function: Invalid option: expected one of "count"|"sum"|"avg"|"min"|"max" (received "median")`
|
|
648
|
+
— the vocabulary is the enum's own, and the author's value is echoed back from
|
|
649
|
+
the input (the one part zod does not put in the message).
|
|
650
|
+
- FROM `aggregate.function "sum" has no "field" to aggregate.`
|
|
651
|
+
TO `aggregate.field: aggregate.function "sum" needs a "field" to aggregate (only "count" may omit it).`
|
|
652
|
+
— verbatim from the schema's refinement.
|
|
653
|
+
|
|
654
|
+
The rule id (`react-chart-aggregate-invalid`) and the severity are unchanged for
|
|
655
|
+
both.
|
|
656
|
+
|
|
657
|
+
**`aggregate.groupBy` missing is a NEW `warning`, deliberately not an error.**
|
|
658
|
+
It is the one violation the platform does not agree with itself about:
|
|
659
|
+
`ChartAggregateSchema` and the published react-blocks type both declare `groupBy`
|
|
660
|
+
**required**, while objectui's `ObjectChart` honours its absence
|
|
661
|
+
(`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own
|
|
662
|
+
`chartAggregateCategoryKey` documents the ungrouped single-row result. Gating it
|
|
663
|
+
would break a working authoring shape to enforce a declaration the platform does
|
|
664
|
+
not keep, so the finding explains the situation and does not fail
|
|
665
|
+
`os lint`/`validate`/`compile`. Whether the schema loosens or the renderer
|
|
666
|
+
tightens is decided on #5583.
|
|
667
|
+
|
|
668
|
+
**What this does NOT fix yet.** `ChartAggregateSchema` and `ChartGroupBySchema`'s
|
|
669
|
+
object arm are still STRIP-posture, so the parse this gate now runs **drops** an
|
|
670
|
+
unknown key rather than reporting it: `groupby` for `groupBy` and
|
|
671
|
+
`dateGranularty` for `dateGranularity` still degrade a chart to one ungrouped
|
|
672
|
+
point with the build green. Wiring the parse is the precondition for closing
|
|
673
|
+
that, not the closing — `.strict()` is a property of a parse, and until now there
|
|
674
|
+
was no parse to make strict. The spec-side tightening is **#5583**; the tolerance
|
|
675
|
+
is pinned by name in this rule's tests so a wired gate cannot be mistaken for a
|
|
676
|
+
closed one (#4583).
|
|
677
|
+
|
|
678
|
+
- bf1edef: feat(formula,lint): wire ADR-0056 D4's RLS authoring gate, from the runtime's own predicate (#4983)
|
|
679
|
+
|
|
680
|
+
`isSupportedRlsExpression` has carried the same docblock since ADR-0056 D4:
|
|
681
|
+
"exposed so an authoring-time gate (`objectstack compile`) can REJECT a
|
|
682
|
+
predicate the runtime would silently drop … A `false` here means 'this
|
|
683
|
+
predicate will never enforce'." It had **no non-test consumer anywhere** — the
|
|
684
|
+
function written to fix declared-but-never-read was itself declared and never
|
|
685
|
+
read. This lands the consumer, in two steps that had to happen in this order.
|
|
686
|
+
|
|
687
|
+
**1. `sqlPredicateToCel` and `isSupportedRlsExpression` move FROM
|
|
688
|
+
`@objectstack/plugin-security` (`src/rls-compiler.ts`) TO `@objectstack/formula`
|
|
689
|
+
(`src/rls-predicate.ts`), and are exported from its root.** Executable code
|
|
690
|
+
unchanged — a change of address, not of behaviour; `plugin-security` now imports
|
|
691
|
+
them from `@objectstack/formula` and keeps no copy, so there is still exactly
|
|
692
|
+
one definition. No import path outside the two packages changes: neither symbol
|
|
693
|
+
was ever exported from `@objectstack/plugin-security`'s entry point. The move is
|
|
694
|
+
what makes step 2 possible at all — `@objectstack/lint` may depend on
|
|
695
|
+
`@objectstack/spec` and never on a runtime, so with the predicate living in a
|
|
696
|
+
runtime the gate's only other door was copying the SQL→CEL bridge, whose
|
|
697
|
+
boundary conditions (quoted literals are never rewritten; canonical CEL passes
|
|
698
|
+
through unchanged) _are_ the gate's red/green line. A fork drifting by one
|
|
699
|
+
character rejects policies the runtime executes correctly — the false-positive
|
|
700
|
+
direction, which is worse than the gap. ADR-0058 D1 asks for a single canonical
|
|
701
|
+
shape gate; the bridge is part of that gate.
|
|
702
|
+
|
|
703
|
+
**2. New `@objectstack/lint` rule `validateRlsPredicateEnforceability`,
|
|
704
|
+
`error`, on all three authoring commands**, over
|
|
705
|
+
`permissions[].rowLevelSecurity[].using` and `.check`:
|
|
706
|
+
|
|
707
|
+
- **`rls-predicate-unenforceable`** — parses as CEL, outside the pushdown
|
|
708
|
+
subset: a function call (`size(...)`, `has(...)`), arithmetic, a ternary, a
|
|
709
|
+
cross-object path (`record.account.region`).
|
|
710
|
+
- **`rls-predicate-unparseable`** — does not parse as CEL even after the legacy
|
|
711
|
+
SQL bridge (`=` → `==`, `IN` → `in`): SQL `AND` / `OR` / `LIKE`, a subquery.
|
|
712
|
+
Its own id because the fix is different — write CEL (`&&`, `||`), not a
|
|
713
|
+
different shape.
|
|
714
|
+
|
|
715
|
+
What the gate prevents, measured through `plugin-security` rather than inferred:
|
|
716
|
+
`RLSCompiler` drops the policy and logs one request-time WARN. On the read path,
|
|
717
|
+
when it is the only applicable policy, `compileFilter` returns the
|
|
718
|
+
`RLS_DENY_FILTER` sentinel instead, which is AND-ed onto the where clause — so
|
|
719
|
+
every select / update / delete on the object matches **zero rows**. On the
|
|
720
|
+
ADR-0058 D4 write path the post-image `check` becomes that same sentinel, which
|
|
721
|
+
no record satisfies, so every insert / update fails with `PermissionDeniedError`.
|
|
722
|
+
The runtime fails closed, which is why this was survivable: the result is not a
|
|
723
|
+
hole but a policy that reads as an authorization and behaves as a blanket
|
|
724
|
+
refusal, with nothing at authoring time pointing at the line that caused it.
|
|
725
|
+
|
|
726
|
+
Fix a flagged predicate by rewriting it inside the lowerable subset — `==` `!=`
|
|
727
|
+
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
|
|
728
|
+
`startsWith` / `endsWith` / `contains` over single-column field paths (ADR-0058
|
|
729
|
+
D2), against a literal or a `current_user.*` value. Two specific migrations:
|
|
730
|
+
`has(x)` / `size(x) > 0` → `x != null` (a function call is correct in an object
|
|
731
|
+
_validation_ rule, which is interpreted, and wrong here, where the predicate is
|
|
732
|
+
compiled to a filter); and a related record's field → denormalise it onto this
|
|
733
|
+
object (formula/rollup) and test that column, since RLS cannot join (ADR-0055).
|
|
734
|
+
|
|
735
|
+
Same construction as the sharing-rule gate (#4698): the rule does not model the
|
|
736
|
+
consumer or grep for it — it calls `isSupportedRlsExpression`, the exact
|
|
737
|
+
function `RLSCompiler.compileFilter` consults to decide whether a dropped policy
|
|
738
|
+
earns its warning, so the two verdicts are one boolean by construction, pinned
|
|
739
|
+
in both directions over a shared corpus. Measured before shipping: every RLS
|
|
740
|
+
predicate declared anywhere in this repo — the `plugin-security` platform seeds,
|
|
741
|
+
the examples, the dogfood fixtures, the authoring skill — is supported, so the
|
|
742
|
+
gate turns nothing red that works today. Unlike the sharing-rule gate, CEL
|
|
743
|
+
_syntax_ is reported here rather than deferred to `expression-invalid`:
|
|
744
|
+
`validateStackExpressions` does not walk `rowLevelSecurity` at all, and could not
|
|
745
|
+
judge this field correctly if it did, because `owner_id = current_user.id` is a
|
|
746
|
+
CEL syntax error and a working RLS predicate at the same time.
|
|
747
|
+
|
|
748
|
+
- 73e576f: feat(lint,spec): SDUI 组件 props 接上解析闸门 —— `ComponentPropsMap` 不再是「声明了、从不被 parse」(#5068)
|
|
749
|
+
|
|
750
|
+
`PageComponent.properties` 是 `z.record(z.string(), z.unknown())` 这个开放口袋。
|
|
751
|
+
`PageComponentSchema` 自 ADR-0089 D3a 起是 `.strict()`,但**严格性不递归**:它守住
|
|
752
|
+
component 节点自己的键,`properties` 里面一个字都没人看。于是 `ComponentPropsMap`
|
|
753
|
+
里 31 个 typed props schema 从来没有被任何东西 parse 过(#4001 批 17 的 `no gate`
|
|
754
|
+
判定:载体活着、parse 缺席)。后果不是无害的 —— objectui 的 `SchemaRenderer` 会把
|
|
755
|
+
`properties` 整个 hoist 到节点上,再把 deny-list 之外的每个键 spread 成 React prop,
|
|
756
|
+
所以一个拼错的键既不被拒绝也不被丢弃:它一路走到渲染器,在那里被忽略,而作者拿到
|
|
757
|
+
的是一张成功回执。这正是 ADR-0078 要消灭的形状。
|
|
758
|
+
|
|
759
|
+
**新规则(两个诊断 id,均为 warning 级)**,落在 `@objectstack/lint`,按维护者对
|
|
760
|
+
#5068 的裁定走方向 A —— 在载体自己的授权门上分派解析,而不是改 `page` 协议的形状:
|
|
761
|
+
|
|
762
|
+
- **`component-props-unknown-key`** —— props schema 未声明的键,包括 props 包自身
|
|
763
|
+
这一层和它底下每一个 strip 姿态的对象。走的是 `lintUnknownKeysAgainstSchema`
|
|
764
|
+
(本次从 `@objectstack/spec` 导出,即 `lintUnknownAuthoringKeys` 用在每个 metadata
|
|
765
|
+
集合上的同一个 walker),所以 strip/strict/passthrough 的姿态规则与改名建议都是
|
|
766
|
+
单一实现,这里不重新推导一遍。
|
|
767
|
+
- **`component-props-invalid`** —— props schema 拒绝的值:类型不对、必填缺失、枚举
|
|
768
|
+
越界。
|
|
769
|
+
|
|
770
|
+
配套的一条契约细节:走到第二层。`readonly`(#5176)挂在 `RecordHighlightsField`
|
|
771
|
+
联合体的对象成员上,即 `fields[]` 数组项里面 —— authorable-surface walk 严格一层、
|
|
772
|
+
到不了那里(#5607 的更正)。本闸门到得了,并且两个方向都钉了测试:声明过的
|
|
773
|
+
`readonly` 必须静默,拼错的 `readOnly` 必须报出来并指名正确拼法。
|
|
774
|
+
|
|
775
|
+
**未注册 type 一律跳过**,这是必须语义而不是宽松:`PageComponentSchema.type` 是
|
|
776
|
+
`z.union([PageComponentType, z.string()])`,光是仓内 example 语料就授权了 10 种本
|
|
777
|
+
map 不承载的类型、共 87 个节点(`flex`、`grid`、`object-metric`、`object-chart`、
|
|
778
|
+
`record:line_items` …),它们的契约在 objectui 注册表和 ADR-0080 manifest 里。拿一个
|
|
779
|
+
不存在的 schema 去审判它们,只会把每一个都报成坏的。
|
|
780
|
+
|
|
781
|
+
**为什么本步只落 warning。**接上 parse 是执法的前置条件,不是执法本身(#5020 在隔壁
|
|
782
|
+
表面上的同一课)。闸门落在真实语料上会报 52 条:其中 34 条是三个已发布平台页把
|
|
783
|
+
`{ en, 'zh-CN' }` 内联多语言 map 写进了声明为纯 `z.string()` 的 `I18nLabelSchema`
|
|
784
|
+
(#5728,裁定中),另有 8 条是 `element:text.content` 上同一形状。今天就 gate 掉它们,
|
|
785
|
+
等于用平台自己都不遵守的声明去否掉平台自己的页面。warning 期的违例清单就是 error
|
|
786
|
+
升级的验收基线,升级本身是独立一步。
|
|
787
|
+
|
|
788
|
+
作者侧不变:`properties` 仍然照原样解析、原样保留,没有任何东西开始被拒绝 ——
|
|
789
|
+
`os validate` / `os build` / `os lint` 多了一类建议性诊断而已。存储路径(`saveMetaItem`
|
|
790
|
+
/ REST `/meta`)仍然不校验 props 包,这一点被如实记录、未在本次修复。
|
|
791
|
+
|
|
792
|
+
- ddd6650: feat(lint): reject a sharing-rule condition the runtime can only skip (#4698)
|
|
793
|
+
|
|
794
|
+
#4698 reported the same failure shape three times in one app in one day: a key
|
|
795
|
+
that is authored, is schema-valid, reads as meaningful — and is never consumed
|
|
796
|
+
by the runtime. Every check verifies that what is declared is _well-formed_,
|
|
797
|
+
never that it is _read_. The issue's third measured instance is a sharing rule
|
|
798
|
+
whose CEL `condition` uses `has(...)`: the seeder cannot lower it, skips the
|
|
799
|
+
rule, and the only signal is one WARN line at boot. The rule exists in
|
|
800
|
+
metadata, is absent from `sys_sharing_rule`, and grants nothing.
|
|
801
|
+
|
|
802
|
+
**New rules, both `error`, on all three authoring commands:**
|
|
803
|
+
|
|
804
|
+
- **`sharing-rule-unlowerable-condition`** — the condition is outside the
|
|
805
|
+
pushdown subset: a function call (`has(...)`, `size(...)`), arithmetic, a
|
|
806
|
+
ternary, or a cross-object path (`record.account.region`).
|
|
807
|
+
- **`sharing-rule-runtime-variable-condition`** — the condition reads
|
|
808
|
+
`current_user.*`. Criteria sharing rules are materialised (one static
|
|
809
|
+
`criteria_json` per rule, from which grants are written), so there is no
|
|
810
|
+
"current user" at compile time. The fix is a different mechanism, not a
|
|
811
|
+
different spelling, which is why it has its own id.
|
|
812
|
+
|
|
813
|
+
Fix each by rewriting the predicate inside the lowerable subset — `==` `!=`
|
|
814
|
+
`>` `<` `>=` `<=`, `in`, `&&` `||` `!`, `== null` / `!= null`, and
|
|
815
|
+
`startsWith` / `endsWith` / `contains` over single-column `record.<field>`
|
|
816
|
+
paths (ADR-0058 D2). Two specific migrations: `has(record.x)` → `record.x !=
|
|
817
|
+
null` (`has()` is correct in an object _validation_ rule, which is
|
|
818
|
+
interpreted, and wrong here, where the condition is compiled); and a related
|
|
819
|
+
record's field → denormalise it onto this object (formula/rollup) and test
|
|
820
|
+
that column, or share the related object instead. For per-user access, use an
|
|
821
|
+
RLS policy (`rowLevelSecurity[].using`), where `current_user.*` _is_ resolved.
|
|
822
|
+
|
|
823
|
+
**Why this one surface and not "unread keys" in general.** "Is this key read?"
|
|
824
|
+
is only a lint question when the answer is computable from the authored
|
|
825
|
+
metadata alone, and usually it is not — a repo-wide grep for a reader is not
|
|
826
|
+
evidence of absence, and a consumer may live in another package, another repo,
|
|
827
|
+
or an uninstalled plugin. A sharing rule's `condition` is the case where the
|
|
828
|
+
predicate is exact: its one runtime consumer
|
|
829
|
+
(`bootstrapDeclaredSharingRules`) does exactly one thing with the key —
|
|
830
|
+
`compileCelToFilter(condition, { variables: {} })` — and a condition that does
|
|
831
|
+
not lower means the rule is skipped outright. So the lint calls that same
|
|
832
|
+
compiler, from the same package, with the same options, instead of modelling
|
|
833
|
+
the consumer; the verdict is identical to the seeder's by construction and is
|
|
834
|
+
pinned in both directions by a test over a shared corpus.
|
|
835
|
+
|
|
836
|
+
`error` rather than advisory, per the ADR-0078 claim `SharingRuleSchema`'s own
|
|
837
|
+
docblock makes ("the whole authorable surface is enforced — nothing here
|
|
838
|
+
validates and then silently does nothing"): there is no reading under which an
|
|
839
|
+
unlowerable condition does what it says. It fails closed, which is why it was
|
|
840
|
+
survivable, not why it was acceptable. Measured before shipping: every
|
|
841
|
+
sharing-rule condition declared anywhere in this repo lowers cleanly, so the
|
|
842
|
+
gate turns nothing red that works today.
|
|
843
|
+
|
|
844
|
+
CEL _syntax_ errors are deliberately left to `expression-invalid`, which
|
|
845
|
+
already gates this same field with a message written about syntax.
|
|
846
|
+
|
|
847
|
+
- 1e6ab15: feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b)
|
|
848
|
+
|
|
849
|
+
- **New rule `unique/unscoped-declared-index`** (warning, advisory): a declared
|
|
850
|
+
index with bare `unique: true` — the spelling whose scope is unstated, the
|
|
851
|
+
#4986 trap. Fires on the spelling alone (no tenancy/posture inference —
|
|
852
|
+
`organization_id` is kernel-injected at registration, so an authoring-time
|
|
853
|
+
guess would be wrong half the time; see #4698). The fix names both words:
|
|
854
|
+
`'global'` (installation-wide — exactly today's behavior) or
|
|
855
|
+
`'organization'` (one holder per organization). Protocol 18 rejects the
|
|
856
|
+
spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` +
|
|
857
|
+
`UNIQUE_UNSCOPED_DECLARED_INDEX`, registered as its own AUTHORING_RULES
|
|
858
|
+
entry (validate/build) and called by `lintDataModel` for `os lint`, so all
|
|
859
|
+
three commands report it — each finding exactly once.
|
|
860
|
+
- **R10 `unique/double-declaration` rewritten as the four-quadrant scope
|
|
861
|
+
matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'`
|
|
862
|
+
(or bare `true`, its deprecated spelling) on the same single column =
|
|
863
|
+
CONTRADICTION (the installation-wide index wins physically; the
|
|
864
|
+
per-organization intent is silently dead) — and the mirror, field `'global'`
|
|
865
|
+
× declared `'organization'`, likewise; same scope on both sides = REDUNDANCY
|
|
866
|
+
(the same index declared twice). The old field-`'global'` exemption is gone
|
|
867
|
+
(now reported as redundancy), and the fix text replaces the hand-written
|
|
868
|
+
`fields: ['organization_id', …]` advice with the `'organization'` spelling —
|
|
869
|
+
the hand-written composite is not NULL-safe (#5030).
|
|
4
870
|
|
|
5
871
|
### Patch Changes
|
|
6
872
|
|
|
873
|
+
- c1e67e0: fix(lint): 扩 ADR-0105 D6 ② 的收件人词表至 ADR 原文范围 —— `unit_and_subordinates`
|
|
874
|
+
也判红
|
|
875
|
+
|
|
876
|
+
`org-axis-cross-org-bu-grant`(D6 ②)此前只对 `sharedWith.type ===
|
|
877
|
+
'business_unit'` 判红,而授权面**更大**的另一个业务单元收件人
|
|
878
|
+
`unit_and_subordinates`(一个 BU **加上其全部后代单元**,ADR-0057 D5 子树扩张)
|
|
879
|
+
直接放行。两者的缺陷完全相同:平台级对象(`tenancy.enabled: false` /
|
|
880
|
+
`systemFields.tenant: false`)没有 organization 列可供 Layer 0 收口,BU 子树没有
|
|
881
|
+
任何 organization 可供解析,授权因而跨到库里每一个 organization —— 正是 ADR 拒绝
|
|
882
|
+
的"跨 org BU 巨树",从后门到达。
|
|
883
|
+
|
|
884
|
+
漏掉的恰恰是 ADR-0105 D6 ② 自己点名的那一个:
|
|
885
|
+
|
|
886
|
+
> Every BU mechanism — `unit_and_subordinates` sharing, `adminScope`
|
|
887
|
+
> delegation, depth scopes — operates within one organization. There is no
|
|
888
|
+
> cross-org tree.
|
|
889
|
+
|
|
890
|
+
判定改为收件人类型 ∈ `{ business_unit, unit_and_subordinates }`,诊断信息里点名
|
|
891
|
+
**实际写下的**类型并说明其触及范围(子树那一个额外写明 "AND every descendant
|
|
892
|
+
unit"),修复建议改为指向三个扁平收件人。
|
|
893
|
+
|
|
894
|
+
词表与 spec 枚举 `ShareRecipientType` 的差集不再是隐式的:规则里以表格逐条写明
|
|
895
|
+
拦截二者、放行 `user` / `team` / `position` 的理由(它们的运行时展开都不经
|
|
896
|
+
`BusinessUnitGraphService`,是 `tenancy.enabled: false` 平台级目录**被设计用来**
|
|
897
|
+
共享的方式),并附一条测试断言两半恰好划分 `ShareRecipientType` —— 将来枚举加成员
|
|
898
|
+
会在词表处失败,而不是无声地落进没人选过的那一桶。#4991 正是这条断言缺席的产物。
|
|
899
|
+
|
|
900
|
+
这是 error 级门禁的扩张,因此复核了真实元数据:`examples/app-showcase` /
|
|
901
|
+
`app-crm` 是仓库里仅有的已声明 sharing rule(共 11 条),全仓无任何对象关掉
|
|
902
|
+
tenancy,扩张后 org-axis 红线数为 **0** —— 不产生新红。
|
|
903
|
+
|
|
904
|
+
- 641363a: fix(docs,lint): 修正两处裸引用公式样例,并给公式样例补一道 CEL 语义门 (#5116)
|
|
905
|
+
|
|
906
|
+
#5026 把字段公式校验从 `f.formula`(spec 按名拒绝的别名)收敛到声明的
|
|
907
|
+
`f.expression`,**激活了一条从未跑过的检查**。它的真实元数据扫描顺带发现文档和博客
|
|
908
|
+
里有两处公式样例写的是**裸引用**:
|
|
909
|
+
|
|
910
|
+
- `content/docs/data-modeling/fields.mdx` — `'quantity * price * (1 - discount / 100)'`
|
|
911
|
+
- `content/blog/context-window-is-the-constraint.mdx` — `` cel`amount * probability` ``
|
|
912
|
+
|
|
913
|
+
裸引用在 CEL 里不报错,而是**静默求值为 null**:公式表达式把记录绑定在 `record`
|
|
914
|
+
命名空间下,顶层的 `quantity` 什么也解析不到。照这两行写出来的元数据,在 #5026
|
|
915
|
+
之后会被 `os build` / `os validate` 判红 —— 文档教的写法和平台的门直接矛盾。两处
|
|
916
|
+
都已改成 canonical 的 `record.` 前缀形式。
|
|
917
|
+
|
|
918
|
+
**新增 `@objectstack/lint` 的 `check:doc-formula-expressions`**,堵住让这两条长期
|
|
919
|
+
存活的那个洞。`check:doc-authoring` 看的是字面量的**形状**,`check:skill-examples`
|
|
920
|
+
对标记块跑 `tsc --noEmit` —— 两者之间,"能编译但 CEL 写错"的样例没有任何门:
|
|
921
|
+
`expression` 的类型就是 `string`,`'quantity * price'` 和
|
|
922
|
+
`'record.quantity * record.price'` 编译得一样好,而只有后者能用。
|
|
923
|
+
|
|
924
|
+
判决直接 import `@objectstack/formula` 的 `validateExpression` —— 和 `os build`
|
|
925
|
+
走的是同一个调用,不是仿制品。于是文档是被**规则本身**把关,而不是被规则的一种方言
|
|
926
|
+
把关(Prime Directive #12)。
|
|
927
|
+
|
|
928
|
+
门的难点不在判决而在**判据**:同一个 `expression:` 键在语料里承载至少三种互不相干的
|
|
929
|
+
契约 —— 记录作用域的 CEL 公式、flow 的扁平作用域谓词(那里裸引用是**对的**)、以及
|
|
930
|
+
`schedule` 下压根不是 CEL 的 cron 串。按键名匹配会把后两类全部误判为红。所以它只认
|
|
931
|
+
**解析后的结构**,且只认两种不可能有歧义的形状:`Field.*({ expression })`,以及
|
|
932
|
+
`type: 'formula'` 与 `expression` 并列的对象字面量。无法提取的块**报错而不是跳过**
|
|
933
|
+
("absence must be loud")。
|
|
934
|
+
|
|
935
|
+
覆盖面是明说的,不含糊:只看 TS/TSX 代码块、只看能静态取出的表达式源、不做字段存在性
|
|
936
|
+
校验(文档片段没有对象声明)、flow / action / validation 谓词**刻意不在范围内**。
|
|
937
|
+
|
|
938
|
+
- d4edb5d: fix(lint): the flow rule family now descends into `loop` bodies and every other nested region (#5383)
|
|
939
|
+
|
|
940
|
+
The flow anti-pattern rules read a flow's `nodes` / `edges` **flat off the top
|
|
941
|
+
level**, so every rule in the family was blind to anything authored inside an
|
|
942
|
+
ADR-0031 container — a `loop` body, a `parallel` branch, a `try_catch`
|
|
943
|
+
try/catch. Loop bodies are where a lot of real branching lives (a per-item gate
|
|
944
|
+
inside a sweep is the standard shape for a scheduled flow), so this was a large
|
|
945
|
+
share of authorable flow metadata that no flow rule inspected.
|
|
946
|
+
|
|
947
|
+
Measured in a real app: 8 `decision` nodes carried the inert singular
|
|
948
|
+
`config.condition` that `flow-inert-node-condition` exists to catch, all 8
|
|
949
|
+
inside a `loop` body, and `pnpm lint` reported none of them. The identical key
|
|
950
|
+
on a **top-level** decision in the same repo fired immediately — same key, same
|
|
951
|
+
node type, only the nesting depth differed. The blind spot also explains its own
|
|
952
|
+
survival: the gate visibly worked where it could see, so the top-level copies
|
|
953
|
+
got cleaned up while the nested ones read as approved.
|
|
954
|
+
|
|
955
|
+
Rules now reported at every depth: `flow-inert-node-condition`,
|
|
956
|
+
`flow-decision-unconditional-branch`, `flow-branch-label-unmatched`,
|
|
957
|
+
`flow-default-edge-with-condition`, `flow-multiple-default-edges`,
|
|
958
|
+
`flow-double-brace-interpolation`, `flow-bare-dollar-reference`,
|
|
959
|
+
`flow-date-equality-filter`, `flow-phantom-aggregation`,
|
|
960
|
+
`flow-error-label-not-fault`, and the `flow-approval-revise-*` family. Note the
|
|
961
|
+
severity asymmetry this closes: `flow-default-edge-with-condition` is a
|
|
962
|
+
build-stopping `error` that until now could not see a contradiction authored one
|
|
963
|
+
level down.
|
|
964
|
+
|
|
965
|
+
A finding inside a region carries the region scope in its `where`, so the
|
|
966
|
+
message still points at exactly one node — `flow 'x' · loop 'sweep' body ·
|
|
967
|
+
node 'y' (decision)`, matching the scope vocabulary the engine's registration
|
|
968
|
+
pass already uses. Findings on a flow's own graph are unchanged, byte for byte.
|
|
969
|
+
|
|
970
|
+
Two details worth knowing if you consume these findings:
|
|
971
|
+
|
|
972
|
+
- Each region is scanned against **its own** `edges`. The branch-routing rules
|
|
973
|
+
reason about a node together with its out-edges, and a region is a
|
|
974
|
+
self-contained sub-graph, so a nested decision's out-edges live in the
|
|
975
|
+
region's own edge list.
|
|
976
|
+
- `flow-double-brace-interpolation` / `flow-bare-dollar-reference` scan a node's
|
|
977
|
+
config recursively, and a container's config physically contains its
|
|
978
|
+
descendants'. A nested hit was therefore already _visible_ before this change
|
|
979
|
+
— but attributed to the enclosing `loop` rather than the node carrying the
|
|
980
|
+
string. Such a finding now names the right node, and is still reported exactly
|
|
981
|
+
once.
|
|
982
|
+
|
|
983
|
+
`flow-runas-unscoped` deliberately keeps looking at top-level nodes only:
|
|
984
|
+
widening a build-gating rule is its own change with its own blast radius, and is
|
|
985
|
+
tracked separately.
|
|
986
|
+
|
|
987
|
+
- eb26126: fix(lint): `flow-runas-unscoped` now sees data nodes nested in a `loop` body / `parallel` branch / `try_catch` region (#5633)
|
|
988
|
+
|
|
989
|
+
**This widens the coverage of a build-GATING rule.** `flow-runas-unscoped` is
|
|
990
|
+
`severity: 'error'`, so a flow it newly catches goes from a green build to a
|
|
991
|
+
failed one. That is the correct outcome — those flows cannot run at all — but it
|
|
992
|
+
is a real blast radius and the reason this shipped as its own change rather than
|
|
993
|
+
riding along with #5383.
|
|
994
|
+
|
|
995
|
+
**What was wrong.** #5383 gave the flow anti-pattern family a per-region walk and
|
|
996
|
+
deliberately left this one rule reading the flow's **top-level** `nodes` only. Its
|
|
997
|
+
data-node search is the rule's evidence that the flow _performs a data operation
|
|
998
|
+
at all_ — and a data node inside a `loop` body is exactly as unscoped as one at
|
|
999
|
+
the top level. So a scheduled flow that queried a set, looped it, and wrote per
|
|
1000
|
+
item passed `os build` / `os validate` clean and was then **refused at run time**:
|
|
1001
|
+
since #3760 a user-less run really does refuse the data operation rather than
|
|
1002
|
+
running it unscoped. Passing the build and then being unable to run is precisely
|
|
1003
|
+
what promoting this rule to `error` was for, and the shape it was missing —
|
|
1004
|
+
query, loop, write per item — is _the_ standard shape for a scheduled flow, so
|
|
1005
|
+
the write is almost always the nested node.
|
|
1006
|
+
|
|
1007
|
+
Measured, same flow with only the node's position changed:
|
|
1008
|
+
|
|
1009
|
+
```
|
|
1010
|
+
update_record at TOP level -> 1 finding [error]
|
|
1011
|
+
update_record INSIDE loop body -> 0 findings (now: 1 finding [error])
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
**What changed.** The data-node search runs across `collectFlowGraphs(flow)` —
|
|
1015
|
+
every ADR-0031 region, at any depth — while the finding itself stays **flow-level**
|
|
1016
|
+
exactly as before: one per flow, `where` = `flow 'x' · runAs`, because `runAs`
|
|
1017
|
+
is a flow property and the region only supplies the evidence. The region is named
|
|
1018
|
+
in the **message** so you can find the node:
|
|
1019
|
+
|
|
1020
|
+
```
|
|
1021
|
+
flow 'nightly_sweep' · runAs: schedule-triggered flow runs under `runAs:'user'`
|
|
1022
|
+
(the default when none is declared), but a schedule run has no trigger user — so its
|
|
1023
|
+
data node 'touch' (update_record), in loop 'loop_rows' body, has no identity to scope
|
|
1024
|
+
to and will be REFUSED at run time.
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
(The sentence's opening was re-worded by #5693 in this same release window; the
|
|
1028
|
+
sample above is the wording that actually ships.)
|
|
1029
|
+
|
|
1030
|
+
**Nothing about the top-level case moved.** A flow whose evidence is a top-level
|
|
1031
|
+
data node produces the same message with no region clause, and when a flow has
|
|
1032
|
+
data nodes at both altitudes the top-level one is still the node cited —
|
|
1033
|
+
`collectFlowGraphs` yields the flow's own graph before it descends. Both are
|
|
1034
|
+
pinned by tests.
|
|
1035
|
+
|
|
1036
|
+
**If this newly fails your build:** the flow was already broken at run time. Add
|
|
1037
|
+
`runAs: 'system'` to declare the elevation the sweep needs (a schedule /
|
|
1038
|
+
time-relative / API run has no user to scope to — there is none). See ADR-0049,
|
|
1039
|
+
ADR-0073 D5, #1888, #3760.
|
|
1040
|
+
|
|
1041
|
+
The repo's three example apps (`app-showcase`, `app-crm`, `app-todo`) are
|
|
1042
|
+
unaffected — `os validate` output is line-for-line identical before and after.
|
|
1043
|
+
|
|
1044
|
+
- be59695: fix(lint): `flow-runas-unscoped` stops telling an author they declared a `runAs` they never wrote (#5693)
|
|
1045
|
+
|
|
1046
|
+
The rule's message branched on whether `runAs` was **authored** or **defaulted**:
|
|
1047
|
+
|
|
1048
|
+
```ts
|
|
1049
|
+
typeof flow.runAs === "string" ? `runAs:'user'` : `the default runAs:'user'`;
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
That distinction is real and useful — "you wrote something incoherent" is not
|
|
1053
|
+
"you inherited a default that does not fit a user-less trigger" — but the rule
|
|
1054
|
+
cannot observe it, and which arm an author got depended on the **surface** rather
|
|
1055
|
+
than on their file.
|
|
1056
|
+
|
|
1057
|
+
**On the CLI, only the explicit arm was reachable.** `FlowSchema.runAs` carries
|
|
1058
|
+
`.default('user')` and the registry wires this rule `input: 'parsed'`, so
|
|
1059
|
+
`flow.runAs` is the string `'user'` whether the author wrote it or not. `os lint`
|
|
1060
|
+
does not Zod-parse, and would have escaped that — except `defineStack` /
|
|
1061
|
+
`defineFlow` parse at _definition_ time, so the config module hands even the
|
|
1062
|
+
non-parsing command a stack with the default already filled in.
|
|
1063
|
+
|
|
1064
|
+
Measured on `examples/app-todo`, `overdue_escalation` with its `runAs` line
|
|
1065
|
+
deleted — the author declared nothing:
|
|
1066
|
+
|
|
1067
|
+
```
|
|
1068
|
+
BEFORE — os validate
|
|
1069
|
+
flow 'overdue_escalation' · runAs: schedule-triggered flow runs as `runAs:'user'`, but a
|
|
1070
|
+
schedule run has no trigger user — so its data node 'get_overdue_tasks' (get_record) …
|
|
1071
|
+
|
|
1072
|
+
BEFORE — os lint
|
|
1073
|
+
✗ flow 'overdue_escalation' · runAs: schedule-triggered flow runs as `runAs:'user'`, but a
|
|
1074
|
+
schedule run has no trigger user — so its data node 'get_overdue_tasks' (get_record) …
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
Both commands told someone who had written no `runAs` that their flow "runs as
|
|
1078
|
+
`runAs:'user'`" — which invites _"I never wrote that, the tool is confused"_ at
|
|
1079
|
+
exactly the moment the tool is right and the fix is one line away.
|
|
1080
|
+
|
|
1081
|
+
Meanwhile the **runtime publish gate** (#4463) judges the verbatim authored body,
|
|
1082
|
+
so it really did reach the other arm — the same flow was told two different
|
|
1083
|
+
things by two shipped surfaces.
|
|
1084
|
+
|
|
1085
|
+
**What changed.** One sentence, true of both authoring inputs, on every surface:
|
|
1086
|
+
|
|
1087
|
+
```
|
|
1088
|
+
AFTER — os validate and os lint, identical
|
|
1089
|
+
flow 'overdue_escalation' · runAs: schedule-triggered flow runs under `runAs:'user'`
|
|
1090
|
+
(the default when none is declared), but a schedule run has no trigger user — so its
|
|
1091
|
+
data node 'get_overdue_tasks' (get_record) has no identity to scope to and will be
|
|
1092
|
+
REFUSED at run time.
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
The parenthetical is a statement about the **value**, not an accusation about the
|
|
1096
|
+
author, so it stays true for someone who did write `runAs:'user'`. This is the
|
|
1097
|
+
house pattern rather than a new one: `flow-draft-status-ambiguous` says `has
|
|
1098
|
+
status 'draft' (the default when none is authored)` for the same reason, on the
|
|
1099
|
+
same mechanism.
|
|
1100
|
+
|
|
1101
|
+
Only the wording moved: the same flows are flagged, with the same
|
|
1102
|
+
`severity: 'error'`, the same `where`, the same `hint`, and the same region
|
|
1103
|
+
clause when the evidence node is nested.
|
|
1104
|
+
|
|
1105
|
+
- b2e1057: fix(lint): report a `config.timeRelative` descriptor the sweep will refuse, at authoring time (#5496)
|
|
1106
|
+
|
|
1107
|
+
A flow start node declaring `config.timeRelative` got **zero** authoring-time
|
|
1108
|
+
diagnostics when its descriptor could not parse. The two rules that look at the
|
|
1109
|
+
slot each looked at something else: `lint-flow-patterns` decides "this is a
|
|
1110
|
+
time-relative flow" from `timeRelative != null` alone (never the shape), and
|
|
1111
|
+
`validate-flow-trigger-readiness`'s existing check reads only
|
|
1112
|
+
`timeRelative.object`, to compare it against the stack's objects. So
|
|
1113
|
+
|
|
1114
|
+
```ts
|
|
1115
|
+
config: { timeRelative: { object: 'task', field: 'due_at', offsetDays: -1 } }
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
— three separate schema violations: `dateField` missing, `offsetDays` declared
|
|
1119
|
+
as an int **array** and written as a scalar, and `field` an unrecognized key —
|
|
1120
|
+
passed `os validate` silently. `TimeRelativeTriggerSchema` does reject it, but
|
|
1121
|
+
the only place that schema ran was **bind time**, inside
|
|
1122
|
+
`TimeRelativeTriggerPlugin.start()`, which warns and returns: the sweep is never
|
|
1123
|
+
installed, the flow reports itself armed, and the author's sole feedback is one
|
|
1124
|
+
line in a server log. For an AI author that line is outside the feedback loop
|
|
1125
|
+
entirely; `os validate` is what it reads.
|
|
1126
|
+
|
|
1127
|
+
**New rule — `flow-time-relative-descriptor-invalid` (warning).** A start node
|
|
1128
|
+
whose `config.timeRelative` is present runs that same schema at authoring time,
|
|
1129
|
+
and a failure is reported naming `config.timeRelative` with the schema's own
|
|
1130
|
+
issue list forwarded — so the diagnostic carries the missing key, the wrong type,
|
|
1131
|
+
and, for an unrecognized key, the "did you mean" the schema already computes
|
|
1132
|
+
(`field` → `dateField`) plus its wrong-layer guidance (a `schedule` written
|
|
1133
|
+
_inside_ the descriptor is told it belongs beside it). The list is rendered
|
|
1134
|
+
exactly as the bind-time warning renders it, so the two channels tell one story.
|
|
1135
|
+
|
|
1136
|
+
Nothing is shifted except **when** the schema runs. No shape knowledge is
|
|
1137
|
+
re-implemented in the rule and no consumer-side tolerance is added: the verdict
|
|
1138
|
+
and every word of its wording remain `TimeRelativeTriggerSchema`'s, so the rule
|
|
1139
|
+
tracks the descriptor's contract as it evolves instead of drifting from a second
|
|
1140
|
+
copy of it.
|
|
1141
|
+
|
|
1142
|
+
The rule and the existing object-name check decide different facts and cannot
|
|
1143
|
+
report the same one twice — only the stack knows whether an object name exists,
|
|
1144
|
+
and only the schema knows the descriptor's shape. A descriptor wrong in both ways
|
|
1145
|
+
gets both findings, at their own paths. Canonical descriptors are unaffected:
|
|
1146
|
+
every one shipped in the repo (the showcase `Task Due Reminder`, the
|
|
1147
|
+
`content/docs` examples) parses, so this adds no diagnostic to existing apps.
|
|
1148
|
+
|
|
1149
|
+
- ec6fad8: feat(lint): warn when a `multi: true` delete/update is bounded by nothing — the declared whole-object write (#5482)
|
|
1150
|
+
|
|
1151
|
+
A `delete_record` / `update_record` node that declares `multi: true` with no
|
|
1152
|
+
`filter` (or an empty one) writes the **whole object**: the executor forwards
|
|
1153
|
+
`where: {}` plus the bulk intent, the data engine classifies that as a legal
|
|
1154
|
+
`multi` call, and it lands on `driver.deleteMany` / `driver.updateMany` with no
|
|
1155
|
+
predicate. Every row, every run.
|
|
1156
|
+
|
|
1157
|
+
That path only became authorable with #5393, which gave these nodes a bulk
|
|
1158
|
+
declaration at all — before it the executor never passed `options.multi`, so the
|
|
1159
|
+
engine refused every predicate write (`Delete requires an ID or
|
|
1160
|
+
options.multi=true`) and "empty filter + bulk intent" was not a reachable shape.
|
|
1161
|
+
Since then it has been reachable and **silent**: `filter` is optional, `multi` is
|
|
1162
|
+
optional, nothing related the two, and the author's only feedback was the step's
|
|
1163
|
+
`acted` row count — reported after the rows were gone. The common way to get
|
|
1164
|
+
here is not malice but an omission: declaring the bulk intent and forgetting the
|
|
1165
|
+
constraint.
|
|
1166
|
+
|
|
1167
|
+
`os validate` / `os build` now report `flow-multi-write-unfiltered` for it:
|
|
1168
|
+
|
|
1169
|
+
```
|
|
1170
|
+
flow 'nightly_purge' · node 'purge' (delete_record)
|
|
1171
|
+
declares `multi: true` with no `filter` key — this is a WHOLE-OBJECT write,
|
|
1172
|
+
by declaration: every row of 'lead' is deleted on every run. …
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
**A warning, not a gate.** An explicit whole-object purge is something the
|
|
1176
|
+
platform grants on purpose — the data engine's own dispatch case-set lists "bulk
|
|
1177
|
+
intent with no predicate at all" as a valid call — so the shape has a legitimate
|
|
1178
|
+
reading and the run-time path stays open. What was missing was only that the
|
|
1179
|
+
author hears about it _before_ the rows go. For the same reason the fix is not a
|
|
1180
|
+
schema `refine`: forbidding the shape would delete an intent the engine grants.
|
|
1181
|
+
|
|
1182
|
+
Two ways to satisfy the warning: write the constraint you mean into `filter`
|
|
1183
|
+
(the bounded-bulk reference shape is app-showcase's `showcase_inquiry_purge`), or
|
|
1184
|
+
confirm that emptying the object is the intent and keep it.
|
|
1185
|
+
|
|
1186
|
+
**It does not duplicate the #3810 run-time guard, which judges a different
|
|
1187
|
+
fact.** That guard refuses a node when a condition the author _wrote_
|
|
1188
|
+
interpolated to nothing (`{record.ownr}` — a typo — leaving `{}`), and it is
|
|
1189
|
+
deliberately keyed on "a written condition is gone" rather than on "the filter is
|
|
1190
|
+
empty", because losing one of two conditions also widens the blast radius. So:
|
|
1191
|
+
|
|
1192
|
+
| fact | judged by | when | verdict |
|
|
1193
|
+
| ----------------------------- | ------------------ | --------- | ------- |
|
|
1194
|
+
| a written condition vanished | #3810 filter guard | run time | refuse |
|
|
1195
|
+
| no condition was ever written | this rule | authoring | warn |
|
|
1196
|
+
|
|
1197
|
+
A node with `filter: { owner: '{record.ownr}' }` is silent for this rule (a
|
|
1198
|
+
condition _is_ written) and refused by that one; a node with no `filter` at all
|
|
1199
|
+
is warned about here and — correctly — allowed there. The diagnostic names the
|
|
1200
|
+
run-time guard so the two are not mistaken for one check.
|
|
1201
|
+
|
|
1202
|
+
Reported at every nesting depth, which matters because a scheduled sweep whose
|
|
1203
|
+
per-item work sits in a `loop` body is the standard janitor shape: a finding
|
|
1204
|
+
inside a region carries the region scope (`flow 'x' · loop 'sweep' body · node
|
|
1205
|
+
'purge' (delete_record)`), on the traversal #5383/#5635 added to this family.
|
|
1206
|
+
|
|
1207
|
+
Deliberately out of range: an empty **combinator** array (`{ $and: [] }`,
|
|
1208
|
+
`{ $or: [] }`). #5322/#5134 ruled those and every driver implements the ruling —
|
|
1209
|
+
empty `$and` is TRUE (so it _is_ a whole-object write), empty `$or` is FALSE (so
|
|
1210
|
+
it matches nothing and must never be warned about) — but telling them apart
|
|
1211
|
+
requires the boolean-identity reduction, which already exists producer-side in
|
|
1212
|
+
each driver. A hand-written fourth copy inside a linter is how a scan and a
|
|
1213
|
+
validator come to answer with two different predicates, so that case is tracked
|
|
1214
|
+
separately instead.
|
|
1215
|
+
|
|
1216
|
+
- 7f1a635: fix(lint,spec,objectql): 编写期表达式与 `highlightFields` 校验识别注册表注入的系统列 (#5378)
|
|
1217
|
+
|
|
1218
|
+
平台在每个业务对象上注入 `owner_id` / `created_at` / `organization_id` 等系统列,
|
|
1219
|
+
文档也把 `ownership: 'user'` 写作 "injects reassignable owner_id"。但编写期的两处
|
|
1220
|
+
校验只读**作者声明的** `fields`,于是注入列一律当作不存在:
|
|
1221
|
+
|
|
1222
|
+
- `validate-expressions.ts` 的 `buildFieldIndex` 让 `has(record.owner_id)` 直接
|
|
1223
|
+
报错 `unknown field owner_id`;
|
|
1224
|
+
- `highlightFields` 存在性检查对 `['owner_id']` 发出 "is not a field on this
|
|
1225
|
+
object — it is silently skipped by every consumer"。
|
|
1226
|
+
|
|
1227
|
+
也就是平台自己的 linter 否认平台自己的契约。结果是应用被迫**重声明系统列**才能通过
|
|
1228
|
+
编写期校验:hotcrm#548 为此在全部 12 个业务对象上显式声明了 `owner_id`(6 个对象曾
|
|
1229
|
+
报 `highlightFields` 警告,`contact_welcome` 触发器的 `has(record.owner_id)` 被硬
|
|
1230
|
+
拒)。这正是本项目视为缺陷的形状:能力已声明(列确实注入且有文档),但执行层不认。
|
|
1231
|
+
|
|
1232
|
+
**权威来源只有一份。** 新增纯派生 `resolveInjectedSystemColumns()`
|
|
1233
|
+
(`@objectstack/spec/data`)回答"这个对象带哪些系统列",并由 registry 的
|
|
1234
|
+
`applySystemFields()` **消费**它——沿用 #3786 为审计字段族确立的分工:spec 声明
|
|
1235
|
+
**有哪些**列,registry 拥有**每列长什么样**。lint 通过同一派生取答案,因此编写期
|
|
1236
|
+
判断与运行时注入不可能不一致(`@objectstack/lint` 的包契约是"只依赖 spec,绝不依赖
|
|
1237
|
+
运行时",此前它根本无法读到权威)。两个消费面共用同一判定,不各写一份。
|
|
1238
|
+
|
|
1239
|
+
**并入是按对象有条件的**,不是无条件放行整张系统列名单:`ownership: 'org' | 'none'`
|
|
1240
|
+
的对象没有 `owner_id`,那里的 `record.owner_id` 仍然是真错误并继续报;
|
|
1241
|
+
`tenancy.enabled: false` 无 `organization_id`;`systemFields: { audit: false }` 无
|
|
1242
|
+
审计四列;`systemFields: false` / `managedBy: 'better-auth'` 什么都不注入(只剩驱动
|
|
1243
|
+
提供的主键 `id`)。真正拼错的字段照旧被拒,并且注入列现在也进入 "did you mean?" 候选
|
|
1244
|
+
(`record.ownerid` → 提示 `owner_id`)。
|
|
1245
|
+
|
|
1246
|
+
被解析的注入列在诊断与补全语义上与授权字段等同;类型健全性与 null-guard 两个索引
|
|
1247
|
+
**刻意**仍只读声明字段,原因写在各自注释里:列的 `type` 与可空性属于 registry 的列
|
|
1248
|
+
定义,在 lint 侧另立一份就是本次要消灭的第二份副本,而 null-guard 喂的是会中断构建的
|
|
1249
|
+
判定,擅自并入会让今天能构建的 stack 变红。
|
|
1250
|
+
|
|
1251
|
+
注入行为本身零改动:`applySystemFields` 的输出在全条件矩阵上逐列不变(新增 parity
|
|
1252
|
+
pin 用实跑注入代码比对)。已显式重声明系统列的应用不受影响——重声明仍然合法,
|
|
1253
|
+
examples 三个 app 的 `os validate` 输出改动前后完全一致。
|
|
1254
|
+
|
|
1255
|
+
- 5d3ced9: fix(objectql,lint)!: a `json_schema` validation rule's `format` keyword is now ENFORCED — records that passed before can start failing (#5029)
|
|
1256
|
+
|
|
1257
|
+
> **⚠️ BEHAVIOUR CHANGE ON DEPLOYED DATA — READ BEFORE UPGRADING.** A `format`
|
|
1258
|
+
> keyword inside a `json_schema` validation rule used to enforce **nothing**.
|
|
1259
|
+
> It now enforces. If any deployed object carries such a rule, writes that
|
|
1260
|
+
> succeeded on the previous version can be **rejected** after this upgrade —
|
|
1261
|
+
> including writes from flows, seeds, imports and integrations, not just the UI.
|
|
1262
|
+
> Nothing about the metadata changed; the runtime simply started honouring what
|
|
1263
|
+
> the metadata always said. See "Before you upgrade" below.
|
|
1264
|
+
|
|
1265
|
+
## What was broken
|
|
1266
|
+
|
|
1267
|
+
`packages/objectql/src/validation/rule-validator.ts` built its shared ajv as
|
|
1268
|
+
`new Ajv({ allErrors: true, strict: false })` and stopped there. In ajv 8 the
|
|
1269
|
+
`format` keyword is **not built in** — it ships in the separate `ajv-formats`
|
|
1270
|
+
package — and under `strict: false` an unregistered format is not an error: ajv
|
|
1271
|
+
logs one line at compile time and **drops the keyword**.
|
|
1272
|
+
|
|
1273
|
+
So this rule:
|
|
1274
|
+
|
|
1275
|
+
```ts
|
|
1276
|
+
{
|
|
1277
|
+
type: 'json_schema',
|
|
1278
|
+
name: 'support_config_shape',
|
|
1279
|
+
field: 'support_config',
|
|
1280
|
+
message: 'Support config is invalid.',
|
|
1281
|
+
schema: {
|
|
1282
|
+
type: 'object',
|
|
1283
|
+
properties: { email: { type: 'string', format: 'email' } },
|
|
1284
|
+
required: ['email'],
|
|
1285
|
+
},
|
|
1286
|
+
}
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
compiled fine, ran on **every** write, enforced `type` and `required` — and
|
|
1290
|
+
enforced **nothing at all** for `format`. `{ email: 'not-an-email' }` was
|
|
1291
|
+
accepted, for every record, forever. The only signal was a stderr line at
|
|
1292
|
+
compile time naming no rule and no object.
|
|
1293
|
+
|
|
1294
|
+
This is the #4649 / #4762 family one level in, and the partial failure is what
|
|
1295
|
+
made it nasty: the rule visibly rejects a bad `type` / missing `required`
|
|
1296
|
+
payload in dev, so it reads as _working_ while the `format` half never fires.
|
|
1297
|
+
`format` is also one of the most reached-for JSON Schema keywords (`email`,
|
|
1298
|
+
`uri`, `uuid`, `date`, `date-time`, `ipv4`), so this was not an exotic corner —
|
|
1299
|
+
and it is exactly the shape an AI writing metadata reaches for first.
|
|
1300
|
+
|
|
1301
|
+
## What changed
|
|
1302
|
+
|
|
1303
|
+
- **`@objectstack/objectql`** now depends on `ajv-formats` and registers it on
|
|
1304
|
+
the shared instance (`addFormats(ajv)`). The **default (full)** format set is
|
|
1305
|
+
used deliberately: `fast` mode trades correctness for speed on precisely the
|
|
1306
|
+
formats authors reach for most, and a format that "mostly" matches is the same
|
|
1307
|
+
declared ≠ enforced defect with a smaller hole.
|
|
1308
|
+
- **`@objectstack/lint`** — the #4762 publish gate
|
|
1309
|
+
(`validate-rule-compilability.ts`) compiles every `json_schema` rule with the
|
|
1310
|
+
SAME ajv environment the runtime uses, on purpose, so it registers the same
|
|
1311
|
+
plugin. This is not cosmetic parity: `ajv-formats` also installs the
|
|
1312
|
+
`formatMinimum` / `formatMaximum` keywords, so a gate without it treats them
|
|
1313
|
+
as unknown keywords (`strict: false` ⇒ silently ignored) and would publish a
|
|
1314
|
+
schema the runtime then refuses to compile — a rule that passes review and
|
|
1315
|
+
enforces nothing, which is the failure that gate exists to prevent. The parity
|
|
1316
|
+
test now reads the plugin registration out of the runtime's source, so the two
|
|
1317
|
+
cannot drift apart silently.
|
|
1318
|
+
|
|
1319
|
+
**Authoring is unchanged.** `format` stays a legal, publishable JSON Schema
|
|
1320
|
+
keyword; the publish gate does not refuse it (option 2 on #5029 was considered
|
|
1321
|
+
and rejected — refusing standard JSON Schema would push authors into private
|
|
1322
|
+
spellings). What changed is only that the declaration is now true.
|
|
1323
|
+
|
|
1324
|
+
## Before you upgrade
|
|
1325
|
+
|
|
1326
|
+
1. Find the rules at risk: any `object.validations[]` entry with
|
|
1327
|
+
`type: 'json_schema'` whose `schema` contains a `format` key, at any depth
|
|
1328
|
+
(including inside `$defs` / `$ref` and a `conditional`'s `then` /
|
|
1329
|
+
`otherwise` branch).
|
|
1330
|
+
2. For each, audit the existing column against that format. Rows already stored
|
|
1331
|
+
are **not** re-validated — nothing is rejected retroactively, and no
|
|
1332
|
+
migration runs — but the **next write that touches the field** is checked,
|
|
1333
|
+
which includes an unrelated PATCH that merely resends the JSON blob.
|
|
1334
|
+
3. If a format was aspirational rather than real, remove that `format` key (or
|
|
1335
|
+
relax it) _before_ upgrading. Deleting the keyword is now a meaningful,
|
|
1336
|
+
visible act rather than a no-op.
|
|
1337
|
+
|
|
1338
|
+
## Known limitation, recorded deliberately
|
|
1339
|
+
|
|
1340
|
+
A **misspelled** format name is still ignored. `format: 'emial'` compiles under
|
|
1341
|
+
`strict: false` — ajv logs `unknown format "emial" ignored` and drops it — in
|
|
1342
|
+
both the runtime and the publish gate, so a typo still enforces nothing. That
|
|
1343
|
+
behaviour is unchanged here and pinned by test in both packages, so it is a
|
|
1344
|
+
known boundary rather than an oversight; closing it is an authoring-time
|
|
1345
|
+
decision of its own and is tracked separately.
|
|
1346
|
+
|
|
1347
|
+
- 4b50be4: fix(lint): a MISSPELLED `format` in a `json_schema` validation rule is refused at publish time (#5178)
|
|
1348
|
+
|
|
1349
|
+
#5029 registered `ajv-formats` so a `json_schema` validation rule's `format`
|
|
1350
|
+
keyword is really enforced. It did **not** close the other half, and said so:
|
|
1351
|
+
under `strict: false` — which is load-bearing, because author-written schemas
|
|
1352
|
+
legitimately carry vendor keywords — an **unrecognised** format name is a
|
|
1353
|
+
non-event. ajv logs one line at compile time and **drops the keyword**:
|
|
1354
|
+
|
|
1355
|
+
```
|
|
1356
|
+
$ node -e "const Ajv=require('ajv'); const addFormats=require('ajv-formats');
|
|
1357
|
+
const ajv=new Ajv({allErrors:true,strict:false}); addFormats(ajv);
|
|
1358
|
+
const v=ajv.compile({type:'object',properties:{e:{type:'string',format:'emial'}}});
|
|
1359
|
+
console.log('validates {e: zzz} =', v({e:'zzz'}));"
|
|
1360
|
+
unknown format "emial" ignored in schema at path "#/properties/e"
|
|
1361
|
+
validates {e: zzz} = true
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
So an author who types `emial`, `e-mail`, `datetime` for `date-time`,
|
|
1365
|
+
`urireference` for `uri-reference`, `ipv_4` for `ipv4` or `Email` for `email`
|
|
1366
|
+
gets a rule that is declared, appears in the metadata, appears in every "what
|
|
1367
|
+
protects this object" listing, runs on every write, enforces `type` and
|
|
1368
|
+
`required` — and enforces nothing for the keyword they actually wrote. The
|
|
1369
|
+
record is **accepted**, which is the silent direction: nothing in the metadata,
|
|
1370
|
+
the UI or a test run says the constraint is inert. A typo is also the single
|
|
1371
|
+
most likely mistake in a hand-written or AI-generated JSON Schema.
|
|
1372
|
+
|
|
1373
|
+
**New gate — `validateRuleSchemaFormats`**, a `gating` entry in
|
|
1374
|
+
`AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`,
|
|
1375
|
+
`os build`, `os lint`) with no per-command wiring. One rule id:
|
|
1376
|
+
|
|
1377
|
+
| id | fires when |
|
|
1378
|
+
| :------------------------------------------- | :---------------------------------------------------------------------------------- |
|
|
1379
|
+
| `validation-rule-json-schema-unknown-format` | a `json_schema` rule's schema names a `format` the runtime's ajv has not registered |
|
|
1380
|
+
|
|
1381
|
+
Each finding names the rule, the object, the **RFC 6901 JSON Pointer** to the
|
|
1382
|
+
offending keyword, and the **nearest registered name**:
|
|
1383
|
+
|
|
1384
|
+
```
|
|
1385
|
+
objects.account.validations.support_shape.schema#/properties/email/format
|
|
1386
|
+
`json_schema` validation 'support_shape' on object 'account' names
|
|
1387
|
+
`format: 'emial'` at `#/properties/email/format`, which is not a registered
|
|
1388
|
+
format. … the schema compiles, the rule ships and runs on every write, its
|
|
1389
|
+
`type`/`required` keywords are enforced, and this constraint is enforced on
|
|
1390
|
+
no record, ever. The record is ACCEPTED, so nothing downstream reports the
|
|
1391
|
+
gap either.
|
|
1392
|
+
hint: Did you mean `format: 'email'`? The registered names are: binary, byte,
|
|
1393
|
+
date, date-time, … — the default `ajv-formats` set, the one
|
|
1394
|
+
`rule-validator.ts` registers (#5029).
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
**The vocabulary is enumerated, never written down.** The registered set is read
|
|
1398
|
+
off a live instance of the very ajv the publish gate builds to mirror the
|
|
1399
|
+
runtime (`registeredFormatNames()`), not from a hardcoded list. A list would be
|
|
1400
|
+
a third opinion that nobody updates: the day `ajv-formats` adds a name, it
|
|
1401
|
+
starts refusing a format the write path enforces — a gate that turns working
|
|
1402
|
+
metadata red gets switched off, and then protects nothing. Enumerating means the
|
|
1403
|
+
gate follows the plugin across an upgrade with no edit at all.
|
|
1404
|
+
|
|
1405
|
+
**The walk is JSON-Schema-aware, because `format` is not a magic word.** Every
|
|
1406
|
+
subschema position is visited — `properties`, `items` (both the 2020-12 schema
|
|
1407
|
+
form and draft-07's tuple array), `anyOf`/`allOf`/`oneOf`/`prefixItems`,
|
|
1408
|
+
`$defs`/`definitions`, `additionalProperties`, `patternProperties`,
|
|
1409
|
+
`if`/`then`/`else`, `not`, `contains`, `propertyNames`, `dependentSchemas`,
|
|
1410
|
+
draft-07 `dependencies` — at any depth, plus the rules nested in a
|
|
1411
|
+
`conditional`'s `then`/`otherwise`. Positions that hold arbitrary **data** are
|
|
1412
|
+
deliberately never read: a `format` inside `default`, `const`, `enum` or
|
|
1413
|
+
`examples` enforces nothing and was never meant to, so reporting it would invent
|
|
1414
|
+
a defect out of a legal document. A non-string `format` (`format: 42`) is left
|
|
1415
|
+
to `validation-rule-json-schema-uncompilable`, which already refuses it in
|
|
1416
|
+
ajv's own words.
|
|
1417
|
+
|
|
1418
|
+
**The runtime is untouched, and so is the #4762/#5029 compile parity.** Option 2
|
|
1419
|
+
on #5178 (make an unknown format a runtime compile error) was rejected: it fires
|
|
1420
|
+
at runtime and fail-**open**, since `checkJsonSchema` catches, logs and skips —
|
|
1421
|
+
trading one silent gap for another. And this is a separate judgement laid
|
|
1422
|
+
_beside_ the existing compile, never folded into it: `validateRuleCompilability`
|
|
1423
|
+
still compiles each schema in the runtime's exact environment and still
|
|
1424
|
+
**publishes** a typo'd format, because a typo'd format compiles there too. Its
|
|
1425
|
+
`#5029` pin ("a MISSPELLED format name is published, not refused") passes
|
|
1426
|
+
verbatim. Two questions, two rules — "does ajv accept this schema?" and "will
|
|
1427
|
+
this `format` keyword do anything?" — sharing one traversal and one ajv
|
|
1428
|
+
environment so they can never disagree about which rules exist or what
|
|
1429
|
+
"registered" means.
|
|
1430
|
+
|
|
1431
|
+
`ajv` / `ajv-formats` stay **lazy**, and this rule is lazier than its neighbour:
|
|
1432
|
+
the registered vocabulary is only fetched once a schema actually names a
|
|
1433
|
+
`format`, so a `json_schema` rule that names none loads neither package. Pinned
|
|
1434
|
+
by the package's `lazy-deps.test.ts` in all three of its layers.
|
|
1435
|
+
|
|
1436
|
+
**Upgrading:** if `os validate` / `os build` / `os lint` newly rejects a
|
|
1437
|
+
`json_schema` validation rule, the format name in it was never being enforced —
|
|
1438
|
+
fix the spelling to the name the finding suggests, or express the constraint
|
|
1439
|
+
with `pattern`, which is enforced. No metadata that was working changes
|
|
1440
|
+
behaviour.
|
|
1441
|
+
|
|
1442
|
+
- 461ccda: fix(lint): 收敛 `validateStackExpressions` / `validateSecurityPosture` 里读 spec 不声明键的 `??` 别名链 (#5017)
|
|
1443
|
+
|
|
1444
|
+
两条规则都以 `input: 'parsed'` 注册,看到的是 `ObjectStackSchema` 解析后的产物。
|
|
1445
|
+
#4984 → #5009 清掉了 sharing rule 字段层和 org-axis 规则里的同形读法;这一轮是同族
|
|
1446
|
+
第三轮,落在另外两个文件。议题点名五条,全包 grep 又找出同形的两条,一并处置:
|
|
1447
|
+
|
|
1448
|
+
| 原读法 | spec 事实 | 处置 |
|
|
1449
|
+
| :-------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :----------------------------- |
|
|
1450
|
+
| `rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula`(两处) | 四个别名全是 `validation.zod.ts` 的 `aliases: { …: 'condition' }` **按名拒绝**的键;canonical 排第三 | 收敛为 `rule.condition` |
|
|
1451
|
+
| `obj.validations ?? obj.validationRules` | `ObjectSchema` 只声明 `validations`,strict 按名拒绝 | 收敛为 `obj.validations` |
|
|
1452
|
+
| `rule.condition ?? rule.criteria ?? rule.predicate` | `criteria` 是运行时编译产物 `criteria_json` 的拼法(#3896),`predicate` 直接拒绝 | 收敛为 `sharingRule.condition` |
|
|
1453
|
+
| `def.reference ?? def.referenceTo` | `field.zod.ts:331` 把 `referenceTo` 映射为 `reference` | 收敛为 `def.reference` |
|
|
1454
|
+
| `action.objectName ?? action.object` | canonical 是 `objectName`;`object` 按名拒绝 | 收敛为 `action.objectName` |
|
|
1455
|
+
| `obj.sharingModel ?? (obj.security)?.sharingModel` | **`ObjectSchema` 根本没有 `security` 键** —— OWD 三个拨盘是平铺的,且 strict:嵌套写法被整包拒绝 | **删除整个 fallback** |
|
|
1456
|
+
| `def.reference ?? def.reference_to` | 同 `referenceTo` | 收敛为 `def.reference` |
|
|
1457
|
+
|
|
1458
|
+
对任何能解析的 stack,判定结果不变 —— 三个 example(crm / showcase / todo)与平台
|
|
1459
|
+
default permission sets 上,改动前后两条规则的 findings 逐字相同。
|
|
1460
|
+
|
|
1461
|
+
**其中一条不是死代码,是活着的错。** `rule.expression ?? … ?? rule.condition ?? …`
|
|
1462
|
+
把 canonical 的 `condition` 排在两个被拒别名之后,所以一条同时写了 `condition` 和
|
|
1463
|
+
`expression` 的规则,lint 校验的是 schema 会拒绝的那个,而作者声明的那个**从头到尾
|
|
1464
|
+
没被看过**:producer 和 consumer 对同一份元数据给出两套说法。测试里重建了旧链来演示
|
|
1465
|
+
这个差异,而不是只描述它。
|
|
1466
|
+
|
|
1467
|
+
真正的代价从来不是漏报,而是误导 —— `object.security.sharingModel` 出现在**安全
|
|
1468
|
+
linter**里,足以让下一位作者(人或 AI)相信对象级 `security` 信封是真实的授权面。
|
|
1469
|
+
|
|
1470
|
+
同时补上两层结构性 meta-guard(#4992 模式,#5018 形状),让下一条死读法在 review
|
|
1471
|
+
前就红:
|
|
1472
|
+
|
|
1473
|
+
- **declared-key guard** —— 规则源码里从每个 surface 上读的键,必须出现在该 surface
|
|
1474
|
+
自己的 Zod `.shape` 里。扫源码不是扫行为是刻意的:不可达分支没有行为可断言。
|
|
1475
|
+
- **reachability guard** —— `validateSecurityPosture` 全部 15 个 `findings.push`
|
|
1476
|
+
落点都必须被一条 schema **不报 `unrecognized_keys`** 的 fixture 触达。判据不是
|
|
1477
|
+
#5018 的 `safeParse` 全绿,而这正是这条规则的特点:它被文档明确设计为也跑在
|
|
1478
|
+
parse 前,好让 `os lint` 对 zod 会拒绝的**值**(`sharingModel: 'read'`)给出更
|
|
1479
|
+
好的信息。被拒的**值**和被拒的**键**是两回事 —— 后者在 parsed 路径上压根到不了。
|
|
1480
|
+
|
|
1481
|
+
七条读法各自做过变异测试:任意一条加回去,都至少有一条测试转红。
|
|
1482
|
+
|
|
1483
|
+
- 58f3220: null-guard 闸门改走 `@objectstack/formula` 的规范解析入口,并移除对 `@marcbachmann/cel-js`
|
|
1484
|
+
的直接依赖(#4812)。
|
|
1485
|
+
|
|
1486
|
+
`validate-null-guards.ts` 此前自建了一个**不带 limits** 的 cel-js `Environment`,于是它会解析、
|
|
1487
|
+
并进而判定平台自身拒绝的谓词 —— 超过 `maxAstNodes` (256) / `maxDepth` (32) /
|
|
1488
|
+
`maxListElements` (64) 的表达式在 lint 侧照常出 finding,在 `compile()` 侧却是
|
|
1489
|
+
`Exceeded max…`。两个解析入口,两个答案,而这个闸门握着更宽松的那个。
|
|
1490
|
+
|
|
1491
|
+
改走 `parseCelToAst` 后两者合一。超界表达式不再由本闸门二次判定,而是交还给同一批调用点上
|
|
1492
|
+
本就在跑的 `validateExpression` —— 它以 blocking error 报告边界错误,措辞面向自纠;作者修好
|
|
1493
|
+
边界问题后,null-guard 判定自然回来。规则判定本身没有变化:#3306 的三元重写对本 pass 是
|
|
1494
|
+
verdict-neutral(重写仅在三元的某一支恰为 `null` 字面量时触发,而该支本就证明不出任何
|
|
1495
|
+
guard),已加测试钉住。
|
|
1496
|
+
|
|
1497
|
+
- f238970: fix(lint): 收敛 `validateRuleCompilability` 里读 spec 不声明键的 `??` 别名链 (#5096)
|
|
1498
|
+
|
|
1499
|
+
#4984 → #5009 → #5017/PR #5046 同族第八处,落在第三个文件
|
|
1500
|
+
(`validate-rule-compilability.ts:239`):
|
|
1501
|
+
|
|
1502
|
+
| 原读法 | spec 事实(对 live `.shape` + `safeParse` 实测) | 处置 |
|
|
1503
|
+
| :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- |
|
|
1504
|
+
| `obj.validations ?? obj.validationRules` | `ObjectSchema.shape` 只声明 `validations`,且 strict —— `validationRules` 被**按名拒绝**:`Unrecognized key(s) on this object: \`validationRules\`. … Did you mean \`validationRules\` → \`validations\`?`(#4001) | 收敛为 `obj.validations` |
|
|
1505
|
+
|
|
1506
|
+
该规则以 `input: 'parsed'` 注册,canonical 排首位,所以别名 limb 对任何能解析的
|
|
1507
|
+
stack 都不可达 —— 三个 example(crm / showcase / todo,共 28 个对象、17 条验证规则)
|
|
1508
|
+
上改动前后 findings **逐字相同**(两侧均 0 条)。
|
|
1509
|
+
|
|
1510
|
+
**代价从来不是漏报,而是误导。** 一个写在 consumer 里的别名 fallback,等于向后来的
|
|
1511
|
+
读者、以及照着这份源码写元数据的 AI 宣称 `objects[].validationRules` 是一个真实的
|
|
1512
|
+
authoring 面;它把 schema 一句指名道姓的拒绝,降级成一条静默失效的分支。别名容忍属于
|
|
1513
|
+
producer 的拒绝面,不属于 consumer(Prime Directive #12)。
|
|
1514
|
+
|
|
1515
|
+
同时给本文件补上两层结构性 meta-guard(#4992 模式,#5017 形状),让下一条死读法在
|
|
1516
|
+
review 前就红:
|
|
1517
|
+
|
|
1518
|
+
- **declared-key guard** —— 规则源码里从 `stack` / `obj` / `rule` 上读的每个键,必须
|
|
1519
|
+
出现在对应 surface 自己的 Zod `.shape` 里,且 `expected` 精确匹配;另加一条 "covers
|
|
1520
|
+
every receiver" 元测试,以及一条针对 `flattenRules` 里 `rule[branch]` **计算属性**
|
|
1521
|
+
读法的专项断言(点号扫描看不见它,而 `then` / `otherwise` 恰是本规则最有意思的读法)。
|
|
1522
|
+
- **reachability guard** —— 两个 `findings.push` 落点都必须被一条 `ObjectStackSchema`
|
|
1523
|
+
**完整 parse 通过**的 fixture 触达。这里用的是 #5018 的 `safeParse` 全绿判据,比
|
|
1524
|
+
`validate-security-posture` 只能要求"不报 `unrecognized_keys`"更严一档 —— 因为本规则
|
|
1525
|
+
判的是"编译不过",而在 spec 眼里 `regex` 是任意字符串、`schema` 是任意 record,编不过
|
|
1526
|
+
的产物依然完全 spec 合法:这条 gate 存在的理由正是 zod 看不见该缺陷,所以它永远不需要
|
|
1527
|
+
一条 zod 会拒绝的 fixture。
|
|
1528
|
+
|
|
1529
|
+
原测试 `reads \`validationRules\` too` 断言的正是被删掉的那条 limb,实测确实产出 1 条
|
|
1530
|
+
finding(不是空转),因此它被**替换**而不是改拼写:把 key 换成 canonical 只会留下一条
|
|
1531
|
+
主题已不存在的绿测试。变异测试:别名 limb 加回去 → 2 条红;改成纯别名读 → 11 条红。
|
|
1532
|
+
|
|
1533
|
+
- 06fc07a: `os validate` now reports a `config.timeRelative` that is not a descriptor object
|
|
1534
|
+
|
|
1535
|
+
A flow start node whose `config.timeRelative` held a scalar — `timeRelative: 'daily'`
|
|
1536
|
+
is the natural mistake, from fusing the sweep's **cadence** with its **descriptor** —
|
|
1537
|
+
was accepted in complete silence at every layer. The node `config` slot is open by
|
|
1538
|
+
design (ADR-0018) so the schema parsed it; the engine routes a flow to the
|
|
1539
|
+
time-relative sweep only when `config.timeRelative` is an object, so the flow fell
|
|
1540
|
+
through that branch and, with no other trigger key on the node, bound to nothing and
|
|
1541
|
+
never fired. Not one diagnostic was produced anywhere — not even the single bind-time
|
|
1542
|
+
warn that an object-but-unparseable descriptor gets, because the trigger was never
|
|
1543
|
+
handed the flow at all.
|
|
1544
|
+
|
|
1545
|
+
A new authoring rule, `flow-time-relative-descriptor-unroutable` (warning), reports it
|
|
1546
|
+
at authoring time with the value, its type, and the consequence — and a hint that
|
|
1547
|
+
separates the two fused concepts: the descriptor says WHICH records to sweep
|
|
1548
|
+
(`{ object, dateField, and exactly one of withinDays | offsetDays }`), while HOW OFTEN
|
|
1549
|
+
is the sibling key `config.schedule`.
|
|
1550
|
+
|
|
1551
|
+
Nothing is made tolerant: a scalar is still not a descriptor and the runtime's
|
|
1552
|
+
behaviour is unchanged. The rule is a separate criterion from
|
|
1553
|
+
`flow-time-relative-descriptor-invalid` (#5496) rather than a widening of it, and the
|
|
1554
|
+
two partition the key along the engine's own routing predicate — a value the engine
|
|
1555
|
+
routes gets the schema's verdict, a value it routes nowhere gets this one, and never
|
|
1556
|
+
both. Arrays and `Date` are `typeof 'object'`, so they stay with the shape rule.
|
|
1557
|
+
|
|
1558
|
+
- 61fde5e: lint: warn when a form section declares a `label` but no `name` — the heading no translation key can ever address
|
|
1559
|
+
|
|
1560
|
+
`_sections` is keyed by the section's `name`, and every renderer that draws a
|
|
1561
|
+
section heading resolves it that way (`sectionLabel(objectName, section.name,
|
|
1562
|
+
authored)` — `plugin-form`'s `ObjectForm`/`ModalForm`, `plugin-detail`'s
|
|
1563
|
+
`record:details`), falling back to the authored label when there is no name.
|
|
1564
|
+
So a section authored with a `label` and no `name` is untranslatable **by
|
|
1565
|
+
construction**, and every gate we own was structurally blind to it:
|
|
1566
|
+
|
|
1567
|
+
- the reference validator reports keys a bundle carries that nothing declares —
|
|
1568
|
+
a nameless section produces no key, so there is no orphan to report;
|
|
1569
|
+
- the i18n coverage walk (#5405) emits one expected key per `sections[].name` —
|
|
1570
|
+
a section with no name contributes nothing to demand, so the report reads
|
|
1571
|
+
100% while the heading renders in the source locale in every locale.
|
|
1572
|
+
|
|
1573
|
+
Measured on HotCRM: **70 of 70** form-view sections across all 14 view files are
|
|
1574
|
+
in exactly that state, with four locales at full declared coverage and zero
|
|
1575
|
+
warnings anywhere. It is also the real cause of the reported `Case / SLA /
|
|
1576
|
+
Resolution` English strip — that object's _detail page_ sections carry names and
|
|
1577
|
+
translate, while its _form view_ sections carry none.
|
|
1578
|
+
|
|
1579
|
+
`validateTranslatableSections` (rule id `translation-section-name-missing`) joins
|
|
1580
|
+
the reference-integrity suite, so it runs on `os validate`, `os lint` and
|
|
1581
|
+
`os compile` at once. It reads exactly the anchors the two landed halves already
|
|
1582
|
+
agree on: a view container's `sections`, its **default** `form.sections`, every
|
|
1583
|
+
`listViews.*` / `formViews.*` sub-container, the same three on views embedded in
|
|
1584
|
+
an object, and `record:details` sections nested anywhere in a page's component
|
|
1585
|
+
tree. `fieldGroups`-derived sections are out of range by construction — their
|
|
1586
|
+
heading is keyed by `fieldGroups[].key`, so they always have a name.
|
|
1587
|
+
|
|
1588
|
+
**Warning, and opt-in.** Nothing crashes and nothing is dead — one heading stays
|
|
1589
|
+
in the source locale — so the severity matches its sibling rules (ADR-0072 D1)
|
|
1590
|
+
and nothing that passed before starts failing. `os validate` over
|
|
1591
|
+
`examples/app-showcase` now reports 14 of these (6 from form views, 8 from
|
|
1592
|
+
`record:details` pages) and still exits 0. A section warns only when the
|
|
1593
|
+
object it renders under carries some translation of its own, which keeps the
|
|
1594
|
+
monolingual case silent exactly as the coverage gate already does.
|
|
1595
|
+
|
|
1596
|
+
The fix is a diagnostic at the **producer**, deliberately not tolerance at the
|
|
1597
|
+
consumer: deriving a lookup key by slugifying the label would fossilize a second
|
|
1598
|
+
de-facto contract next to the declared one, and would move the day anyone edits
|
|
1599
|
+
the heading text. The `name` the hint suggests is a suggestion for the author to
|
|
1600
|
+
write down, never a key anything resolves.
|
|
1601
|
+
|
|
1602
|
+
- 471839d: fix(spec): classify the 22 dashboard widget keys and refuse undeclared container inheritance (#4956)
|
|
1603
|
+
|
|
1604
|
+
The spec liveness ledger's `dashboard.widgets` entry carried one blanket `live`
|
|
1605
|
+
verdict plus a `note` asserting that the per-widget props were _"classified in
|
|
1606
|
+
the DashboardWidgetSchema subtree"_. **No such subtree ever existed.** The gate's
|
|
1607
|
+
walk drills one level and only through an explicit `children`, and `widgets`
|
|
1608
|
+
declared none — so all 22 authorable keys of the strict `DashboardWidgetSchema`
|
|
1609
|
+
were never classified, never counted as unclassified, and every run printed
|
|
1610
|
+
"all governed-type properties are classified" anyway.
|
|
1611
|
+
|
|
1612
|
+
That gap — not evidence — is what carried `widgets[].responsive` through the
|
|
1613
|
+
#3896 inert-key sweep that removed both its sibling `widgets[].performance` and
|
|
1614
|
+
its literal namesake `view.responsive`. `view` is drilled through `children`, so
|
|
1615
|
+
`list.responsive` got asked and went out; `widgets` was never asked. It was
|
|
1616
|
+
finally retired in #4876 / PR #4995, by hand, four days late.
|
|
1617
|
+
|
|
1618
|
+
**What changed for authors**
|
|
1619
|
+
|
|
1620
|
+
The `objectstack build` / `objectstack lint` advisory now covers dashboards, so
|
|
1621
|
+
five widget keys warn at build time (they never did before — `dashboard` was not
|
|
1622
|
+
in the lint's type collections, because until now its ledger warned on nothing):
|
|
1623
|
+
|
|
1624
|
+
| Widget key | Why it warns | What to do instead |
|
|
1625
|
+
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------- |
|
|
1626
|
+
| `widgets[].colorVariant` | no render path reads the top-level key — only the authoring panels do | move it under `options` (the inline metric card reads it there); the dataset-bound path has no colour affordance |
|
|
1627
|
+
| `widgets[].actionUrl` | no renderer draws a per-widget action button; every `actionUrl` the dashboard renderer reads belongs to `header.actions[]` | use `dashboard.header.actions[]` |
|
|
1628
|
+
| `widgets[].actionType` | pairs with the above | as above |
|
|
1629
|
+
| `widgets[].actionIcon` | zero readers in either repo | as above |
|
|
1630
|
+
| `widgets[].aria` | declared ARIA attributes never reach the DOM — the same false-compliance shape as the dashboard-level `aria` removed in 17.0.0 | delete it; the renderer emits its own `aria-*` |
|
|
1631
|
+
|
|
1632
|
+
Advisory only — the build never fails on these. **Nothing is removed and no
|
|
1633
|
+
runtime behaviour changes**: this records verdicts, it does not act on them.
|
|
1634
|
+
Enforce-or-remove (ADR-0049) for the five is tracked separately.
|
|
1635
|
+
|
|
1636
|
+
Two verdicts worth knowing because they cut the other way: `requiresService` is
|
|
1637
|
+
**live** — it reads as inert in the renderer repo but the REST layer strips
|
|
1638
|
+
widgets whose service is unregistered (ADR-0057 D10) — and `compareTo` is live
|
|
1639
|
+
on the inline chart path only; on the ADR-0021 dataset path the string arms are
|
|
1640
|
+
dropped and `{ offset }` fails in the analytics executor.
|
|
1641
|
+
|
|
1642
|
+
**What changed for the gate**
|
|
1643
|
+
|
|
1644
|
+
`pnpm --filter @objectstack/spec check:liveness` gains a third direction. A
|
|
1645
|
+
ledger entry sitting on a container property must now declare one of exactly
|
|
1646
|
+
three dispositions, all of them data: **drilled** (`children`), **deferred** (a
|
|
1647
|
+
`{ container, to }` row naming the coordinate that does classify the subtree),
|
|
1648
|
+
or **recorded** (a row in the shrink-only
|
|
1649
|
+
`scripts/liveness/undrilled-containers.baseline.json`). A container in none of
|
|
1650
|
+
the three fails, and so does a baseline row whose container has since been
|
|
1651
|
+
drilled.
|
|
1652
|
+
|
|
1653
|
+
A deferral is **resolved, not believed** — the target must exist (a governed
|
|
1654
|
+
type root, or a drilled `type/prop` coordinate) and classify exactly the
|
|
1655
|
+
container's child keys; a dangling or drifted target fails. That is the #4956
|
|
1656
|
+
claim itself, made checkable: pointing a deferral at `DashboardWidgetSchema`
|
|
1657
|
+
now produces a build failure naming it, where the same words in a `note` were
|
|
1658
|
+
believed for a release.
|
|
1659
|
+
|
|
1660
|
+
Every run reports both populations (today: 58 containers / 292 child keys
|
|
1661
|
+
classified nowhere, plus 6 resolved deferrals covering 248), `--undrilled`
|
|
1662
|
+
prints the worklist, and the success line no longer claims a completeness it
|
|
1663
|
+
does not have.
|
|
1664
|
+
|
|
1665
|
+
- b821b29: fix(lint): 清除 `validateOrgAxisRedLines` 里 spec 合法 stack 永远到不了的四条分支 (#5009)
|
|
1666
|
+
|
|
1667
|
+
`validate-org-axis-red-lines.ts` 是 `input: 'parsed'` 规则 —— 它看到的是
|
|
1668
|
+
`ObjectStackSchema` 解析后的产物。#4984 修掉了 sharing rule 字段那一层的 `??`
|
|
1669
|
+
别名读法,但同一文件里还留着四条同形分支,每一条读的键 spec 都不声明:
|
|
1670
|
+
|
|
1671
|
+
| 原读法 | spec 事实 | 处置 |
|
|
1672
|
+
| :-------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------ |
|
|
1673
|
+
| `cfg.permissions ?? cfg.permissionSets` | stack 根 **strip** 未声明键,`permissionSets` 解析后必为 `undefined` | 收敛为 `cfg.permissions` |
|
|
1674
|
+
| `cfg.sharingRules ?? cfg.sharing`(两处) | 同上 | 收敛为 `cfg.sharingRules` |
|
|
1675
|
+
| `str(rule.object ?? rule.objectName)` | `SharingRuleSchema` 是 `.strict()`,按名拒绝 `objectName`;`object` 又是必填 | 收敛为 `rule.object` |
|
|
1676
|
+
| `asArray(object.rowLevelSecurity ?? object.rls)` 整段(约 20 行) | **`ObjectSchema` 两个键都不声明**,且 `.strict()` —— 带对象级 RLS 的 stack 在 `os validate` / `os build` 直接被拒("Unrecognized key(s) on this object") | **删除** |
|
|
1677
|
+
|
|
1678
|
+
对任何 spec 合法的 stack,判定结果不变:这些分支本来就永远不执行(反向验证 ——
|
|
1679
|
+
新测试跑在改动前的实现上,29 条由 `safeParse` fixture 驱动的断言全绿)。真正的
|
|
1680
|
+
代价从来不是漏报,而是误导:对象级 RLS **根本不是授权面**(`authorable-surface.json`
|
|
1681
|
+
里只有 `security/PermissionSet:rowLevelSecurity` 一条),而那段死代码连
|
|
1682
|
+
`objects[N].rowLevelSecurity[M].using` 的诊断 path 都写好了,足以让下一位作者
|
|
1683
|
+
(人或 AI)相信它是真的并照着写更多代码 —— #5008 差点就这么做了。
|
|
1684
|
+
|
|
1685
|
+
行为上唯一的差别落在 `os lint`(不 parse,跑 normalized 层):把别名拼法写进
|
|
1686
|
+
stack 的作者,不再从这条红线拿到诊断,而是从 schema 那里拿到一条指名道姓的
|
|
1687
|
+
拒绝。别名容忍属于 producer 的拒绝,不属于 consumer(Prime Directive #12)。
|
|
1688
|
+
|
|
1689
|
+
同时补上一层结构性 meta-guard(#4992 模式),让下一条死分支在 review 前就红:
|
|
1690
|
+
|
|
1691
|
+
- **declared-key guard** —— 规则源码里从 stack / permission set / RLS policy /
|
|
1692
|
+
object / sharing rule 上读的每一个键,都必须出现在对应 schema 自己的 `.shape`
|
|
1693
|
+
里。扫源码而不是扫行为是刻意的:不可达分支根本没有行为可断言。
|
|
1694
|
+
- **reachability guard** —— 每个 `findings.push` 调用点都必须被至少一条过
|
|
1695
|
+
`safeParse` 的 fixture 触达;走不到的分支不允许存在。
|
|
1696
|
+
- 规则 ① 的 fixture 现在也走 `PermissionSetSchema.safeParse`(此前只有 sharing
|
|
1697
|
+
rule 和 object fixture 有这层保护)。
|
|
1698
|
+
|
|
1699
|
+
四条分支各自被变异测试验证过:把任意一条加回去,都至少有两条测试转红。
|
|
1700
|
+
|
|
1701
|
+
- da1a64c: fix(lint): reject a validation rule whose regex or JSON Schema does not compile, at authoring time (#4762)
|
|
1702
|
+
|
|
1703
|
+
Two of the six object validation-rule types carry a **static artifact** that the
|
|
1704
|
+
write path hands to a real compiler, inside a `try/catch` that logs and returns
|
|
1705
|
+
`null`:
|
|
1706
|
+
|
|
1707
|
+
- `format` → `new RegExp(rule.regex)` → _"Validation rule '…' has an invalid regex — skipped"_
|
|
1708
|
+
- `json_schema` → `ajv.compile(rule.schema)` → _"Validation rule '…' has an uncompilable JSON Schema — skipped"_
|
|
1709
|
+
|
|
1710
|
+
"Skipped" means the rule is declared, appears in the metadata, appears in every
|
|
1711
|
+
"what protects this object" listing — and enforces nothing, on every record, for
|
|
1712
|
+
as long as the metadata is deployed, with a WARN line in a log nobody reads as
|
|
1713
|
+
the only signal. That is the shape #4649 was filed about one rule type over;
|
|
1714
|
+
#4761 flipped the CEL predicates to fail closed and deliberately left these two,
|
|
1715
|
+
because their blast radius differs (see below).
|
|
1716
|
+
|
|
1717
|
+
**New gate — `validateRuleCompilability`**, a `gating` entry in
|
|
1718
|
+
`AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`,
|
|
1719
|
+
`os build`, `os lint`) with no per-command wiring. Two rule ids:
|
|
1720
|
+
|
|
1721
|
+
| id | fires when |
|
|
1722
|
+
| :----------------------------------------- | :----------------------------------------------------------- |
|
|
1723
|
+
| `validation-rule-regex-uncompilable` | a `format` rule's `regex` throws in `new RegExp(...)` |
|
|
1724
|
+
| `validation-rule-json-schema-uncompilable` | a `json_schema` rule's `schema` throws in `ajv.compile(...)` |
|
|
1725
|
+
|
|
1726
|
+
Each finding names the rule, the object and the config path, and carries the
|
|
1727
|
+
**compiler's own error text verbatim** — an author cannot act on "invalid
|
|
1728
|
+
regex", but can act on `Invalid regular expression: /([/: Unterminated character
|
|
1729
|
+
class`. Rules nested in a `conditional`'s `then` / `otherwise` are judged too
|
|
1730
|
+
(`evaluateRule` recurses into them and reaches the very same checkers), and the
|
|
1731
|
+
finding names the branch it is in.
|
|
1732
|
+
|
|
1733
|
+
**Detection is the real compilers, never a pattern that judges a pattern.** The
|
|
1734
|
+
regex is compiled with `new RegExp(source)` — the exact call `checkFormat`
|
|
1735
|
+
makes. The schema is compiled with ajv constructed with the **same options the
|
|
1736
|
+
runtime's shared instance uses** (`{ allErrors: true, strict: false }`), read
|
|
1737
|
+
back out of `rule-validator.ts`'s source by a parity test so the day those
|
|
1738
|
+
options change, this gate is told rather than left quietly disagreeing.
|
|
1739
|
+
`strict: false` is load-bearing in both directions: a gate running `strict: true`
|
|
1740
|
+
would reject author-written schemas carrying vendor keywords that the write path
|
|
1741
|
+
compiles happily — a gate that turns working metadata red gets switched off, and
|
|
1742
|
+
then protects nothing.
|
|
1743
|
+
|
|
1744
|
+
`ajv` is a new dependency of `@objectstack/lint`, loaded **lazily**: only a
|
|
1745
|
+
stack that actually declares a `json_schema` validation rule pays for it, pinned
|
|
1746
|
+
by the package's `lazy-deps.test.ts` alongside `typescript` and `sucrase`. The
|
|
1747
|
+
kernel boot path (`@objectstack/lint/runtime`) never loads it at all.
|
|
1748
|
+
|
|
1749
|
+
**The runtime half is deliberately unchanged.** `rule-validator.ts` still fails
|
|
1750
|
+
open on both, and the `#4649 — unchanged neighbours` pins that record it stand
|
|
1751
|
+
exactly as they are. A broken regex or schema is _static_ — decidable from the
|
|
1752
|
+
metadata alone, with no record in hand — so the authoring door closes the class
|
|
1753
|
+
outright without ever bricking a running deployment, whereas rejecting at write
|
|
1754
|
+
time would reject **every** write touching that field for as long as the bad
|
|
1755
|
+
metadata is deployed. Whether a runtime backstop is still wanted on top of a
|
|
1756
|
+
closed authoring door stays open on #4762.
|
|
1757
|
+
|
|
1758
|
+
- 5e3c83b: fix(lint): 七个规则 id 常量补进 barrel —— 消费者不必再对字面量,并加一条测试面门禁 (#5648)
|
|
1759
|
+
|
|
1760
|
+
规则把自己的 id 写进每条 finding 的 `f.rule`,而那个字符串就是 `os lint --json` / `os validate` 递到消费者手上的东西:Studio 的 finding 渲染、下游按规则过滤/抑制、以及被授权元数据里的 `suppressWarnings: ['<rule-id>']`。规则文件为此导出同名常量,消费者本该比对常量而不是重敲 slug。
|
|
1761
|
+
|
|
1762
|
+
但 `packages/lint` 的 `package.json#exports` 只开 `"."` 与 `"./runtime"`(`tsup.config.ts` 的 entry 也只有这两个),所以**没进 barrel 就不是「不好取」,而是完全取不到** —— 没有深路径可绕,消费者唯一的退路正是那个常量本来要消灭的字符串字面量。
|
|
1763
|
+
|
|
1764
|
+
#5648 报的是其中一个(`FLOW_TRIGGER_UNKNOWN_EVENT`,#3427/#3457/#3481 三条规则共用的 id)。它要求的全量清查又翻出**六个**,散在五个规则文件、成因都远早于它:`APPROVAL_APPROVER_TYPE_UNSUPPORTED`、`SECURITY_FLS_UNQUALIFIED_KEY`、`FIELD_GROUP_SHADOWED`、`WIDGET_LEGACY_ANALYTICS_SHAPE`、`WIDGET_LEGACY_ANALYTICS_UNRENDERABLE`、`REACT_CHART_DRILLDOWN_INVALID`。其中 `WIDGET_LEGACY_ANALYTICS_SHAPE` 最能说明代价:规则打给用户的提示原话就是 `Suppress with suppressWarnings: ['widget-legacy-analytics-shape']`,即它主动教消费者用这个 id,却不让消费者拿到承载它的常量。
|
|
1765
|
+
|
|
1766
|
+
漏项之所以能一路静默:规则照常工作,它自己的单测**从规则文件直接 import 常量**(不经 barrel),于是唯一会发现的时刻是有人从包外去消费它。同一种一行漏项独立发生七次,不是「下次记牢」能解决的记性问题,而是缺一条判定。
|
|
1767
|
+
|
|
1768
|
+
**因此判定权移进 `packages/lint` 测试面**(`src/rule-id-barrel-exports.test.ts`):新增规则时忘了 barrel 那行,会在新规则自己的测试转绿的同一次 run 里失败。分层理由是这条不变量完全是包内的 —— 没有别处定义 lint 规则 id —— 且它要**真的 import** barrel 来核验取值,vitest 天然给得到;换成 `scripts/` 门禁则要么自己写一个 ES 解析器,要么先构建 dist,还会把反馈挪到另一个 job。
|
|
1769
|
+
|
|
1770
|
+
门禁按两类假绿反向设计:发现面是对 `src/` 的**文件系统读取**而非手写清单(新规则文件一存在即被枚举),并对 id 条数压一条下限,避免将来改坏提取式后在空集上「全绿」;两个 entry 虽是静态列出(全动态 import 无法可靠打包),但另有一条用例从 `package.json#exports` 与 `tsup.config.ts` **各自独立**推导出同一集合并比对,新增第三个 entry 若不登记就会红,而不是悄悄不被检查。
|
|
1771
|
+
|
|
1772
|
+
分类按**取值形状**而非发射位置判定:早先一版靠「定义旁边有 `rule: NAME`」来认,结果漏掉了经辅助函数参数发射的四个 `REACT_CHART_*` —— 恰好包含本次真实漏项之一。
|
|
1773
|
+
|
|
1774
|
+
仅新增导出面,无行为变化:任何既有 `f.rule` 字符串都没有改动,原先对字面量的消费者继续可用。
|
|
1775
|
+
|
|
1776
|
+
- 20963e7: fix(lint): `translation-target-unknown` reads a view container's DEFAULT `form.sections` (#5415)
|
|
1777
|
+
|
|
1778
|
+
`validate-translation-references` derives the `_sections` names an object may
|
|
1779
|
+
legally be translated by from a list of anchors: `fieldGroups[].key`, the named
|
|
1780
|
+
sections on `listViews.*` / `formViews.*`, the named sections on a page's
|
|
1781
|
+
`record:details` component, and the view record's own `sections`. The list was
|
|
1782
|
+
missing one: the view CONTAINER's **default form** — the `form` that
|
|
1783
|
+
`defineView({ list, form, formViews })` declares and that `ObjectForm` renders
|
|
1784
|
+
when no named form view is asked for.
|
|
1785
|
+
|
|
1786
|
+
`collectViewRecord` iterated `['listViews', 'formViews']`, and `view.form` is
|
|
1787
|
+
neither of those nor the record's own `sections`, so `view.form.sections[].name`
|
|
1788
|
+
contributed **nothing** to the fact set. The renderer resolves those headings
|
|
1789
|
+
through exactly the same `sectionLabel(object, section.name, …)` convention as
|
|
1790
|
+
any named form view, so a bundle that correctly translated one of them was
|
|
1791
|
+
reported as keyed to a section "which nothing on object X declares", with a hint
|
|
1792
|
+
advising the author to delete a translation that renders. On the in-repo
|
|
1793
|
+
showcase contact surface — whose object declares `field.group` and no
|
|
1794
|
+
`fieldGroups[]`, so the default form is its **only** section anchor — all four
|
|
1795
|
+
headings were in that state, and the hint went as far as "declares no named
|
|
1796
|
+
section at all".
|
|
1797
|
+
|
|
1798
|
+
The default form now feeds the same section collector as `formViews.*`, bound
|
|
1799
|
+
by `bindingOf(view.form) ?? listBinding` — i.e. `form.data.object` first, then
|
|
1800
|
+
the record-level object, then the list beside it — which is the resolution the
|
|
1801
|
+
CLI i18n walker performs for the same surface, so the rule that DEMANDS a key
|
|
1802
|
+
and the rule that ACCEPTS one agree on which object a heading belongs to. Each
|
|
1803
|
+
anchor is now a call into one collector rather than its own copy of the loop.
|
|
1804
|
+
|
|
1805
|
+
Nothing tightens: an unnamed section is still untranslatable (it has no stable
|
|
1806
|
+
key to look up), and a `_sections` key no anchor declares is still reported —
|
|
1807
|
+
now with the real anchors enumerated in the hint.
|
|
1808
|
+
|
|
1809
|
+
- Updated dependencies [9fe9c1d]
|
|
1810
|
+
- Updated dependencies [d4e0809]
|
|
1811
|
+
- Updated dependencies [f724f69]
|
|
1812
|
+
- Updated dependencies [28ad90e]
|
|
1813
|
+
- Updated dependencies [f8644c7]
|
|
1814
|
+
- Updated dependencies [306ca50]
|
|
1815
|
+
- Updated dependencies [978fed2]
|
|
1816
|
+
- Updated dependencies [cfc293f]
|
|
1817
|
+
- Updated dependencies [de70b42]
|
|
1818
|
+
- Updated dependencies [fb3d99b]
|
|
1819
|
+
- Updated dependencies [cdfbee2]
|
|
1820
|
+
- Updated dependencies [29c6c9d]
|
|
1821
|
+
- Updated dependencies [d21c001]
|
|
1822
|
+
- Updated dependencies [f1cc3a3]
|
|
1823
|
+
- Updated dependencies [ddc2527]
|
|
1824
|
+
- Updated dependencies [553a47f]
|
|
1825
|
+
- Updated dependencies [a3a884d]
|
|
1826
|
+
- Updated dependencies [cfed092]
|
|
7
1827
|
- Updated dependencies [2e284b2]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1828
|
+
- Updated dependencies [1b49eaf]
|
|
1829
|
+
- Updated dependencies [0161c7f]
|
|
1830
|
+
- Updated dependencies [e900015]
|
|
1831
|
+
- Updated dependencies [b5bdf48]
|
|
1832
|
+
- Updated dependencies [a019e52]
|
|
1833
|
+
- Updated dependencies [64fc6d5]
|
|
1834
|
+
- Updated dependencies [947d4f9]
|
|
1835
|
+
- Updated dependencies [eaaf03c]
|
|
1836
|
+
- Updated dependencies [d17df80]
|
|
1837
|
+
- Updated dependencies [7d0e7b5]
|
|
1838
|
+
- Updated dependencies [6513c17]
|
|
1839
|
+
- Updated dependencies [c142ced]
|
|
1840
|
+
- Updated dependencies [eda599e]
|
|
1841
|
+
- Updated dependencies [c001422]
|
|
1842
|
+
- Updated dependencies [77022a9]
|
|
1843
|
+
- Updated dependencies [52760bf]
|
|
1844
|
+
- Updated dependencies [5543020]
|
|
1845
|
+
- Updated dependencies [880d343]
|
|
1846
|
+
- Updated dependencies [6e82972]
|
|
1847
|
+
- Updated dependencies [4615a18]
|
|
1848
|
+
- Updated dependencies [7f62706]
|
|
1849
|
+
- Updated dependencies [667fa44]
|
|
1850
|
+
- Updated dependencies [37e38d1]
|
|
1851
|
+
- Updated dependencies [0f17114]
|
|
1852
|
+
- Updated dependencies [1eb13a0]
|
|
1853
|
+
- Updated dependencies [c52e608]
|
|
1854
|
+
- Updated dependencies [4dfd002]
|
|
1855
|
+
- Updated dependencies [77be690]
|
|
1856
|
+
- Updated dependencies [811c30c]
|
|
1857
|
+
- Updated dependencies [b49ccfd]
|
|
1858
|
+
- Updated dependencies [85d95e7]
|
|
1859
|
+
- Updated dependencies [168f60f]
|
|
1860
|
+
- Updated dependencies [244ca86]
|
|
1861
|
+
- Updated dependencies [546ab3c]
|
|
1862
|
+
- Updated dependencies [58f3220]
|
|
1863
|
+
- Updated dependencies [07f1822]
|
|
1864
|
+
- Updated dependencies [0b51bb6]
|
|
1865
|
+
- Updated dependencies [d9971d3]
|
|
1866
|
+
- Updated dependencies [abeb375]
|
|
1867
|
+
- Updated dependencies [ef4efa8]
|
|
1868
|
+
- Updated dependencies [cbb6a5c]
|
|
1869
|
+
- Updated dependencies [795b6e1]
|
|
1870
|
+
- Updated dependencies [175d789]
|
|
1871
|
+
- Updated dependencies [55dbbba]
|
|
1872
|
+
- Updated dependencies [72c3c86]
|
|
1873
|
+
- Updated dependencies [7f1a635]
|
|
1874
|
+
- Updated dependencies [502564d]
|
|
1875
|
+
- Updated dependencies [471839d]
|
|
1876
|
+
- Updated dependencies [b508244]
|
|
1877
|
+
- Updated dependencies [594508e]
|
|
1878
|
+
- Updated dependencies [1c625ca]
|
|
1879
|
+
- Updated dependencies [71f205d]
|
|
1880
|
+
- Updated dependencies [414395b]
|
|
1881
|
+
- Updated dependencies [26e1029]
|
|
1882
|
+
- Updated dependencies [108ba8d]
|
|
1883
|
+
- Updated dependencies [b4ad984]
|
|
1884
|
+
- Updated dependencies [a9f32df]
|
|
1885
|
+
- Updated dependencies [aeb9b27]
|
|
1886
|
+
- Updated dependencies [7d27da0]
|
|
1887
|
+
- Updated dependencies [089767f]
|
|
1888
|
+
- Updated dependencies [e4c8b6c]
|
|
1889
|
+
- Updated dependencies [acb10f6]
|
|
1890
|
+
- Updated dependencies [1c3da1f]
|
|
1891
|
+
- Updated dependencies [a34fd2e]
|
|
1892
|
+
- Updated dependencies [889ae47]
|
|
1893
|
+
- Updated dependencies [4f4c3fb]
|
|
1894
|
+
- Updated dependencies [7adc841]
|
|
1895
|
+
- Updated dependencies [4845f85]
|
|
1896
|
+
- Updated dependencies [bf1edef]
|
|
1897
|
+
- Updated dependencies [7b005b4]
|
|
1898
|
+
- Updated dependencies [94f7b6a]
|
|
1899
|
+
- Updated dependencies [5c94f83]
|
|
1900
|
+
- Updated dependencies [73e576f]
|
|
1901
|
+
- Updated dependencies [c5a5996]
|
|
1902
|
+
- Updated dependencies [ae490ef]
|
|
1903
|
+
- Updated dependencies [f61c8cf]
|
|
1904
|
+
- Updated dependencies [e3ef52b]
|
|
1905
|
+
- Updated dependencies [07f1822]
|
|
1906
|
+
- Updated dependencies [04fab5e]
|
|
1907
|
+
- Updated dependencies [efedd28]
|
|
1908
|
+
- Updated dependencies [5278e11]
|
|
1909
|
+
- Updated dependencies [23dba62]
|
|
1910
|
+
- Updated dependencies [ba98e26]
|
|
1911
|
+
- Updated dependencies [fc5f536]
|
|
1912
|
+
- Updated dependencies [f8cfbb4]
|
|
1913
|
+
- Updated dependencies [c89d18c]
|
|
1914
|
+
- Updated dependencies [aac90a5]
|
|
1915
|
+
- Updated dependencies [1e6ab15]
|
|
1916
|
+
- Updated dependencies [c87ef70]
|
|
1917
|
+
- Updated dependencies [3cb0618]
|
|
1918
|
+
- Updated dependencies [32a0874]
|
|
1919
|
+
- Updated dependencies [7055c22]
|
|
1920
|
+
- Updated dependencies [785a748]
|
|
1921
|
+
- Updated dependencies [3af0354]
|
|
1922
|
+
- Updated dependencies [866ff16]
|
|
1923
|
+
- Updated dependencies [5a85e67]
|
|
1924
|
+
- Updated dependencies [c183a12]
|
|
1925
|
+
- Updated dependencies [8064b07]
|
|
1926
|
+
- Updated dependencies [4a56dbd]
|
|
1927
|
+
- Updated dependencies [06df4fa]
|
|
1928
|
+
- @objectstack/spec@17.0.0-rc.4
|
|
1929
|
+
- @objectstack/formula@17.0.0-rc.4
|
|
1930
|
+
- @objectstack/sdui-parser@17.0.0-rc.4
|
|
11
1931
|
|
|
12
1932
|
## 17.0.0-rc.2
|
|
13
1933
|
|