Repository navigation
Commit 86fb645
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XVb2k5se7JG54uScQenh4XD)_
Fixes #28659
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx`
now says a non-option field is refused at save;
`skills/objectstack-formula/SKILL.md` already teaches `label()` as
select-only and its eval `cel-predicates-formulas.json` exercises that
## 维护者速读
- **改了什么**:公式里写 `label(record.某字段)`,而这个字段没有声明选项(比如文本字段)时,保存对象现在会被拒绝(422
`expression-invalid`),报错点名字段并给出改法;以前会保存成功,然后每一行都读成空值。
- **为什么改**:`label()` 只对带选项的字段有意义,运行时每行都报错、静默变 null,只在服务端日志留一条
WARN。保存时已经拒绝未知字段和未注册函数,这一类漏掉了。
- **风险与回滚**:这是一次收窄(`Metadata-protocol: narrowed`),formula 与 lint 升
minor。被拒的公式本来就每行都是 null,没有能用的写法会被拒。回滚即 revert 本 PR。
-
**先看**:`packages/formula/src/validate.ts`(`checkOptionLabelTargets`)、`packages/formula/src/stdlib.ts`(`declaresOptions`
与共用句子
`optionlessLabelSentence`)、`packages/lint/src/validate-expressions.ts`(公式检查传入
`optionFields`)。
- **两分钟验证**:`pnpm --filter @objectstack/formula exec vitest run
src/validate-option-label-target.test.ts`,预期 14 个用例全绿。
- **需要你做的**:无,席位落地。
## What changes
- **`@objectstack/formula`**
- `validateExpression` gains an opt-in `ExprSchemaHint.optionFields`:
the object's fields that declare options. When it is given (with
`fields`), every `label(record.FIELD)` call that names a declared field
outside it is refused under a new code in the closed set,
`label-field-no-options` (`params: { field }`), one refusal per field. A
field `fields` does not declare keeps its `unknown-field` refusal and
gets no second one. Absent ⇒ the check does not run, so every other
caller is unchanged.
- The field a call names is read through `optionLabelFieldOf`, the
reader the `label` macro itself uses (now exported package-internally,
not from the entry), so the gate and the evaluator agree on which field
a call names.
- The refusal message is the evaluator's own fault sentence for the same
call, factored out of `optionLabelOf` as `optionlessLabelSentence`
(byte-identical to the WARN line), so there is no second message family.
- Two exported readings of "declares options", side by side in
`stdlib.ts`: `declaresOptions(def)` on the SERVED form
(`Array.isArray(def.options)`, what the engine builds `label()`'s map
from), and `declaresOptionsOnceServed(def)` on the AUTHORED form (inline
`options`, or a non-empty `picklist` name, which the registry resolves
onto the served field; R8's reading). Review round 1 (6559777722) found
the first version read the authored body through the served-form
predicate and refused a working picklist-bound `label()`.
- **`@objectstack/objectql`**: `formula-option-labels.ts` builds
`label()`'s map through `declaresOptions` instead of its own spelling of
the same test. No behaviour change.
- **`@objectstack/lint`**: the formula pass (`judgeFieldFormula`, the
body of the `expression-invalid` rule `validateStackExpressions`) hands
the validator `optionFields`: the object's declared fields that
`declaresOptionsOnceServed`, plus any injected system column that does
(none does today). No new rule id: the finding is `expression-invalid`,
located at the formula field's `expression`.
- `content/docs/data-modeling/formulas.mdx`: one sentence on the
refusal.
The claim named `packages/lint/src/authoring-rules.ts` as the file
surface. The gate's body lives in `validate-expressions.ts`, which
`authoring-rules.ts` runs; `authoring-rules.ts` itself is unchanged.
`packages/formula/src/` grew by the check and one code, and
`packages/objectql` by the one-predicate reuse the order asked for.
## Tests
- `packages/formula/src/validate-option-label-target.test.ts` (new, 14,
incl. the `declaresOptionsOnceServed` table and the authored/served
picklist pair): the card's text case and compound case refused naming
`first`; one refusal per field; nested calls judged; controls (select
and radio with options admitted, unknown field and unregistered function
unchanged, a direct read admitted, opt-in absent ⇒ nothing judged); the
refusal equals the evaluator's fault message for the same call;
`declaresOptions` truth table.
- `packages/formula/src/expression-refusal-codes.test.ts`: a pin for
`label-field-no-options` (code, params, message), and the sweep gains a
`label()` source plus `optionFields` on its typed schema, so the new arm
is provoked and its params checked against the message.
- `packages/lint/src/runtime-gate.object-formula-label-target.test.ts`
(new, 14): `os validate`, `os build`, `os lint` and the runtime object
gate (`runRuntimeAuthoringRules`) each refuse both bodies at `object
'qaformula_p4' · field 'pf' expression`; controls clean at all four,
including a `select` bound to a shared `picklist` with no inline
`options` (`label(record.tier)` and a compound with it); unknown-field
and unregistered-function refusals unchanged.
- `packages/lint/src/validate-expressions.test.ts`: the #5017 receiver
meta-test excuses the two plumbing locals (`fieldList`, `declaredNames`)
whose keys are Array/Set methods; the one metadata key read, `options`,
goes through `declaresOptions`.
<details><summary>Red first on base (the six source files swapped to
base, tests kept)</summary>
Formula read from `src/`, lint from the rebuilt formula `dist/`
(preflight: marker absent from all 6 built files).
- formula: 7 failed, 30 passed (every LIT case, the `declaresOptions`
table, the closed-set pin).
- lint: 8 failed, 4 passed (all eight LIT cases; the four controls hold
on base by construction).
- Restored with `git checkout HEAD --` per path; `git diff HEAD` empty
and `git status --porcelain` clean.
</details>
<details><summary>Ablation: drop the options check</summary>
`node scripts/ablation-replace.mjs` on
`packages/formula/src/validate.ts`, anchor `!withOptions.has(field) &&
!seen.has(field)` → `!withOptions.has(field) && false &&
!seen.has(field)` (anchor 1 → 0, blob `e7327b51ea43` → `991a89c6da33`).
Formula rebuilt: the JS bundle carries the marker
(`ablation-dist-preflight` hit `dist/index.js`); the DTS step exits 1
because the dead branch loses narrowing (TS2345), which does not touch
the JS the suites run.
- formula: 5 failed, 6 passed (the five refusal cases; controls hold).
- lint: 8 failed, 4 passed (all LIT cases).
- Restore: blob back to `e7327b51ea43` == HEAD, `git diff HEAD` empty;
formula rebuilt, marker absent from `dist/`, `label-field-no-options`
present again.
An earlier ablation leg (`… && false && …` placed before the
`withOptions` read) failed the whole formula build and is discarded:
that leg's lint run read a stale `dist/`.
</details>
<details><summary>Live REST red/green on the showcase (the card's
reproduction)</summary>
Stock showcase boot (`pnpm dev -- --fresh`), admin sign-in, scratch
package `com.objectstack.qa.formula` installed (201), then `PUT
/api/v1/meta/object/NAME?package=com.objectstack.qa.formula` with
`first` text, `kind` select with options, `pf` formula.
| Body (`pf`) | Base (main + six files at base) | This branch |
|---|---|---|
| `label(record.first)` | 200; `POST /data` → `"pf": null`, one WARN
"declares no options" | 422 `INVALID_METADATA`, issue
`expression-invalid` at `object 'qaformula_p4' · field 'pf' expression`,
message the evaluator's sentence naming `first` |
| `label(record.kind) + label(record.first)` | 200 | 422, same issue |
| `label(record.kind)` (control) | 200 | 200; `POST /data` → `"pf":
"Clean"` |
| `label(record.nope)` (control) | 422 unknown field | 422 unknown
field, unchanged |
| draft `label(record.first)` then `POST …/publish` | draft 200, publish
200 | draft 200 (drafts are not gated), `GET ?state=draft`
`_diagnostics` carries the same `expression-invalid` error, publish 422
|
</details>
<details><summary>Ablation (review round 1): drop the picklist
half</summary>
`node scripts/ablation-replace.mjs` on
`packages/lint/src/validate-expressions.ts`, anchor
`fieldList.filter(([, f]) => declaresOptionsOnceServed(f))` →
`declaresOptionsOnceServed({ ...f, picklist: undefined })` (anchor 1 →
0), under the verify lock.
- lint `runtime-gate.object-formula-label-target.test.ts`: 2 failed, 12
passed. The two that fail are the picklist controls
(`label(record.tier)` and `label(record.tier) + ' / ' +
label(record.kind)`), refused as the first version refused them; every
LIT case and every other control holds.
- Restore: blob back to `511b9f3952a6` == HEAD, `git diff HEAD` empty.
- No live REST probe was run for the picklist control; the runtime-gate
unit test drives the same `runRuntimeAuthoringRules` the save door runs.
</details>
## Acceptance notes
- **Which faces run the gate.** `os validate`, `os build`, `os lint`,
the object save door, draft `_diagnostics`, the per-item publish and the
history restore door all reach `validateStackExpressions` and agree
(unit pins for the CLI commands and the runtime gate, the live table
above for the door, draft and publish).
- **Two faces that do not run it**, reported, not changed here:
- The MCP `validate_expression` tool
(`packages/mcp/src/mcp-http-tools.ts`) calls `validateExpression` with
`fields` and `fieldTypes` only. Both of its bridges
(`runtime/src/domains/mcp.ts`, `mcp/src/stdio-data-bridge.ts`) project
each field to `name/type/label/required`, so the tool cannot see
options; it answers ok for `label(record.first)` where the door answers
422. Closing it means projecting options presence through both bridges.
- Studio's live CEL lint (`ui/…/metadata-admin/celAuthoring.ts`) passes
`fields` and `scope` only, as it already does for `fieldTypes`; the
door's 422 still reaches Studio on save.
- **Changeset level.** The order asked for `patch`; the changeset is
`minor` for formula and lint (objectql `patch`). AGENTS.md ties the
level to the `Metadata-protocol` kind, and a value the door stored
before is now refused, which is `narrowed`;
`check-changeset-no-major.mjs` refuses `narrowed` without a `minor`.
ADR-0087: `not-required (no-migration-prescription)`; the refused
formula already reads null on every row, so nothing a conversion could
rewrite works today.
- **Multi-value selects.** `label()` on a `multiselect` that declares
options is admitted (it declares options); the evaluator still refuses a
list value per row, as before. That is the evaluator's existing verdict,
not widened or narrowed here.
- **Predicates.** `label()` in a validation rule or other predicate
already faults (no option labels there) and is documented as
formula-only; this card covers the formula field `expression`, the site
the card measured.
## Verification
Head `c41843d6` (review round 1 fix on top of `eb107dcc`, the clean
merge of `origin/main` after #28679 landed).
- Gate list from `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: **89 derived, 89 run, 0 NOT-MEASURED, 0
UNRUN** (`--ran` reconciliation exit 0), every one exit 0.
`check:type-check-debt` first answered exit 3 (it queued behind the
dogfood run holding the verify lock, nothing measured), and
`check:skill-examples` / `check:dual-build-cjs-loads` answered before
the full build had run; all three exit 0 on the re-run. The record is
`pm-orders/server/28659-gates-ran.txt` in the project files.
- `check:changeset-no-major` locally: no `major`; the level axis reads
the PR body in CI.
- Full dogfood once on the final head (owed by the `(narrowing)` arm),
under the lock: `pnpm --filter @objectstack/dogfood test` exit 0, Test
Files 371 passed, 3 skipped (374); Tests 2994 passed, 28 skipped (3022).
<details><summary>Package suites on head c41843d</summary>
- `@objectstack/formula`: test exit 0, Test Files 62 passed, Tests 1593
passed; typecheck (incl. `check:test-typecheck`) exit 0.
- `@objectstack/lint`: test exit 0, Test Files 185 passed, Tests 7732
passed; typecheck exit 0.
- `@objectstack/objectql`: test exit 0, Test Files 513 passed, Tests
9901 passed; typecheck exit 0.
- `ExpressionRefusalParams` / `EXPRESSION_REFUSAL_CODES` readers outside
`packages/formula`: none (`git grep`), so the new code reaches no
catalogue that must list it.
</details>
The repo-wide farm, `test:repo` and `pnpm lint` belong to CI; read this
PR's check-runs.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5pQB2Jwsce3RvocUcS)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d278c9 commit 86fb645
12 files changed
Lines changed: 504 additions & 14 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages
- formula/src
- lint/src
- objectql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
298 | 298 | | |
299 | 299 | | |
300 | 300 | | |
301 | | - | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
302 | 305 | | |
303 | 306 | | |
304 | 307 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
374 | 374 | | |
375 | 375 | | |
376 | 376 | | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
377 | 393 | | |
378 | 394 | | |
379 | 395 | | |
| |||
451 | 467 | | |
452 | 468 | | |
453 | 469 | | |
| 470 | + | |
454 | 471 | | |
455 | 472 | | |
456 | 473 | | |
| |||
477 | 494 | | |
478 | 495 | | |
479 | 496 | | |
| 497 | + | |
480 | 498 | | |
481 | 499 | | |
482 | 500 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
174 | 174 | | |
175 | 175 | | |
176 | 176 | | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
177 | 183 | | |
178 | 184 | | |
179 | 185 | | |
| |||
222 | 228 | | |
223 | 229 | | |
224 | 230 | | |
| 231 | + | |
225 | 232 | | |
226 | 233 | | |
227 | 234 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
92 | 92 | | |
93 | 93 | | |
94 | 94 | | |
95 | | - | |
| 95 | + | |
96 | 96 | | |
97 | 97 | | |
98 | 98 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
420 | 420 | | |
421 | 421 | | |
422 | 422 | | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
423 | 428 | | |
424 | | - | |
| 429 | + | |
425 | 430 | | |
426 | 431 | | |
427 | 432 | | |
428 | 433 | | |
429 | 434 | | |
430 | 435 | | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
431 | 493 | | |
432 | 494 | | |
433 | 495 | | |
| |||
450 | 512 | | |
451 | 513 | | |
452 | 514 | | |
453 | | - | |
454 | | - | |
455 | | - | |
456 | | - | |
457 | | - | |
458 | | - | |
459 | | - | |
| 515 | + | |
460 | 516 | | |
461 | 517 | | |
462 | 518 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
0 commit comments