Skip to content

Commit 86fb645

Browse files
fix(formula,lint)!: a formula whose label() names a field that declares no options is refused at author time (#28659) (#28714)
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
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(formula,lint)!: a formula whose `label()` names a field that declares no options is refused at author time
8+
9+
`label(record.FIELD)` answers on a field whose definition carries an `options` list; on any other field the evaluator faults on every row, so the formula read `null` forever with a WARN line as the only trace. The `expression-invalid` gate refused `label(record.nope)` (unknown field) and `sqrt(4)` (unregistered function) but admitted `label(record.first)` on a text field. It now refuses that too, at `os validate`, `os build`, `os lint` and every door that runs the active authoring gate (the object save door, per-item publish, history restore and a draft's `_diagnostics`; one evaluation, `runRuntimeAuthoringRules`), in the evaluator's own words (the same sentence its WARN line carries), naming the field and the remedy.
10+
11+
Clause-②: no (narrowing) — the object save door and the CLI authoring commands refuse a formula whose `label()` argument names a declared field that declares no options, which they stored before. Nothing is widened.
12+
13+
Metadata-protocol: narrowed (a formula field's `expression` may no longer call `label()` on a field that declares no `options`)
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed. The refused value never worked: the evaluator faults on that call on every row, so a stored formula of this shape reads null for every reader today, and no conversion could give it a label to read. The refusal names the field and the remedy (read the field directly, or point label() at a field that declares options). -->
16+
17+
**BREAKING** accept-set narrowing at the authoring gate (`@objectstack/formula`, `@objectstack/lint`), shipped as `minor`.
18+
19+
- **Before.** `PUT /api/v1/meta/object/NAME` with a formula `label(record.first)` (`first` a text field), or `label(record.kind) + label(record.first)`, answered 200; every row then read the formula as `null`.
20+
- **Now.** The same write answers 422 `expression-invalid` naming `first`, located at the formula field's `expression`; `os validate` / `os build` / `os lint` report the same finding.
21+
- **Unchanged.** `label()` on a field that declares options is admitted and evaluates as before: inline `options` on any type (`select`, `multiselect`, `radio`, …), or a `picklist` naming a shared list, which the registry resolves onto the served field. The unknown-field and unregistered-function refusals are unchanged and an unknown field gets no second refusal.
22+
- **Who sees it.** An author whose formula calls `label()` on a field without options, a formula that already reads null on every row.
23+
24+
**The remedy.** Read the field directly (`record.first`), or point `label()` at a field that declares `options` or names a `picklist`.
25+
26+
`validateExpression` gains an opt-in `ExprSchemaHint.optionFields` (the object's option-bearing fields) and the closed refusal-code set gains `label-field-no-options` (`params: { field }`). `@objectstack/formula` exports the two readings of "declares options" side by side: `declaresOptions(def)` on the served form (`@objectstack/objectql` builds `label()`'s map through it) and `declaresOptionsOnceServed(def)` on the authored form (inline `options`, or a `picklist` the registry writes onto the served field), which `@objectstack/lint`'s formula pass hands the validator, so the gate refuses exactly the fields the evaluator faults on.

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,10 @@ then a shared picklist's `picklists.<picklist>.options.<value>`), exactly as
298298
`Accept-Language`, so the write response the save toast reads, the record
299299
page title, related lists and lookups all follow it.
300300

301-
- `label()` takes exactly `record.<field>`, a field that declares `options`.
301+
- `label()` takes exactly `record.<field>`, a field that declares `options`
302+
or names a `picklist`. Any other field is refused when the object is saved
303+
(`os validate`, `os build`, the object save door), with the field named,
304+
because it has no label to read and the formula would read null on every row.
302305
An empty value reads `''`; a stored value no option declares reads as itself.
303306
A multi-value select is refused.
304307
- A read with no locale (a script, a scheduled job) reads the declared labels.

‎packages/formula/src/expression-refusal-codes.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,22 @@ const PINS: { readonly [C in ExpressionRefusalCode]: readonly [Pin<C>, ...Pin<C>
374374
'time and reject the write. Denormalise the value you need onto `account`\'s object, or read it in a hook instead.',
375375
},
376376
],
377+
'label-field-no-options': [
378+
{
379+
// #28659: the evaluator's own fault sentence, at author time.
380+
produce: () =>
381+
onlyError('value', 'label(record.first)', {
382+
scope: 'record',
383+
objectName: 'p4',
384+
fields: ['first', 'kind'],
385+
optionFields: ['kind'],
386+
}),
387+
params: { field: 'first' },
388+
message:
389+
'label(record.first): `first` declares no options, so it has no option label. ' +
390+
"label() reads a select field's option label; read any other field directly (record.first).",
391+
},
392+
],
377393
'empty-expression': [
378394
{
379395
produce: () => rootsRefusal(' '),
@@ -451,6 +467,7 @@ const SWEEP_SOURCES: readonly string[] = [
451467
"type == 'grid' && status == 'q'",
452468
'has(status) && other == 1',
453469
'daysBetween(record.a, record.b) > 3',
470+
"label(record.name) + label(record.status)",
454471
];
455472

456473
const SWEEP_INPUTS: readonly ExprInput[] = [
@@ -477,6 +494,7 @@ const SWEEP_SCHEMAS: readonly (ExprSchemaHint | undefined)[] = [
477494
fields: ['amount', 'close_date', 'name', 'active', 'account', 'status'],
478495
fieldTypes: { close_date: 'date', name: 'text', active: 'boolean', account: 'lookup' },
479496
roleCatalog: ['org_admin', 'sales'],
497+
optionFields: ['status'],
480498
},
481499
{ fields: ['status', 'close_date'], objectName: 'deal', fieldTypes: { close_date: 'date' } },
482500
{ fields: ['status'] },

‎packages/formula/src/expression-refusal.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ export interface ExpressionRefusalParams {
174174
'traversal-bare-and-traversed': { readonly root: string; readonly field: string };
175175
/** A reference field read through more than one relationship hop. */
176176
'traversal-multi-hop': { readonly root: string; readonly field: string };
177+
/**
178+
* `label(record.<field>)` naming a declared field that declares no options
179+
* (`ExprSchemaHint.optionFields`, #28659). The message is the evaluator's own
180+
* fault sentence for that call.
181+
*/
182+
'label-field-no-options': { readonly field: string };
177183
/** `collectCelRootIdentifiers` was handed no expression. */
178184
'empty-expression': Readonly<Record<string, never>>;
179185
/** `collectCelRootIdentifiers` could not parse the source; `detail` is the parser's reason. */
@@ -222,6 +228,7 @@ const CODE_TABLE = {
222228
'field-near-miss': true,
223229
'traversal-bare-and-traversed': true,
224230
'traversal-multi-hop': true,
231+
'label-field-no-options': true,
225232
'empty-expression': true,
226233
'cel-parse-failed': true,
227234
} as const satisfies Record<ExpressionRefusalCode, true>;

‎packages/formula/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export {
9292
MAIL_HTML_SUPPORTED_SHAPES,
9393
} from './mail-html-hole-placement';
9494
export type { MailHtmlHole, MailHtmlHolePlacement, MailHtmlHoleJudgeOptions } from './mail-html-hole-placement';
95-
export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate, isUnparseableDate } from './stdlib';
95+
export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate, declaresOptions, declaresOptionsOnceServed, isUnparseableDate } from './stdlib';
9696
export type { PermissionBinding } from './stdlib';
9797
// objectui#4421 / batch #147 — the permission predicate's data door. `can` reads
9898
// `EvalContext.permissions` and nothing else, and this is how that map is built

‎packages/formula/src/stdlib.ts‎

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -420,14 +420,76 @@ const LABEL_ARGUMENT_REFUSAL =
420420
/**
421421
* The field `label()`'s argument names — `kind` for `label(record.kind)` — or
422422
* `undefined` when the argument is any other shape.
423+
*
424+
* Package-internal, not re-exported from the entry: the macro reads its field
425+
* through it, and `validateExpression`'s option check (#28659) reads the field
426+
* of each `label(` call in a parsed source through it too, so the authoring
427+
* gate and the evaluator agree on WHICH field a call names.
423428
*/
424-
function optionLabelFieldOf(node: CelMacroNode | undefined): string | undefined {
429+
export function optionLabelFieldOf(node: CelMacroNode | undefined): string | undefined {
425430
if (!node || node.op !== '.' || !Array.isArray(node.args)) return undefined;
426431
const [receiver, field] = node.args as [CelMacroNode | undefined, unknown];
427432
if (!receiver || receiver.op !== 'id' || receiver.args !== 'record') return undefined;
428433
return typeof field === 'string' && field.length > 0 ? field : undefined;
429434
}
430435

436+
/**
437+
* Does this SERVED field definition declare options — the reading of "has
438+
* options" `label()` depends on at evaluation (#28659)?
439+
*
440+
* A field whose served definition carries an `options` list is a field the
441+
* engine builds a label map for (`@objectstack/objectql`'s
442+
* `formula-option-labels.ts` reads the served object's fields through this),
443+
* so `label(record.<field>)` answers on it. Any other field is absent from
444+
* that map, and {@link optionLabelOf} faults on every row.
445+
*
446+
* It reads the declaration, not the type: a `select` that declares no options
447+
* has no labels either (and is refused for that by its own completeness rule),
448+
* and any field type that does declare a list is answered.
449+
*
450+
* An AUTHORED definition is read through {@link declaresOptionsOnceServed}
451+
* instead: a field bound to a shared picklist is authored without `options`
452+
* and served with them.
453+
*/
454+
export function declaresOptions(def: unknown): boolean {
455+
return !!def && typeof def === 'object' && Array.isArray((def as { options?: unknown }).options);
456+
}
457+
458+
/**
459+
* Will this AUTHORED field definition declare options once it is served — the
460+
* reading an authoring gate judges `label()` by (#28659)?
461+
*
462+
* The spec gives a `select` / `radio` two option sources, mutually exclusive
463+
* (`FieldSchema`): inline `options`, or a `picklist` naming a shared list.
464+
* The registry writes a picklist-bound field's resolved options onto the
465+
* served object (`@objectstack/objectql`'s `picklist-resolution.ts`), so the
466+
* served definition {@link declaresOptions} and `label()` reads the list's
467+
* labels; a name no list declares is refused at load. So an authored field
468+
* answers when it declares `options` or names a picklist — the R8 reading in
469+
* `@objectstack/lint`'s `data-model-rules.ts` — and `@objectstack/lint`'s
470+
* formula pass reads the authored body through this, so the authoring gate
471+
* refuses exactly the fields the evaluator would fault on.
472+
*/
473+
export function declaresOptionsOnceServed(def: unknown): boolean {
474+
if (declaresOptions(def)) return true;
475+
const picklist = !!def && typeof def === 'object' ? (def as { picklist?: unknown }).picklist : undefined;
476+
return typeof picklist === 'string' && picklist !== '';
477+
}
478+
479+
/**
480+
* The sentence for `label(record.<field>)` on a field that declares no options.
481+
* Shared by the evaluation fault ({@link optionLabelOf}) and the authoring
482+
* refusal (`validateExpression`'s `label-field-no-options`, #28659), so the
483+
* author reads the same prescription at the door that the log line carries.
484+
*/
485+
export function optionlessLabelSentence(field: string): string {
486+
return (
487+
`label(record.${field}): \`${field}\` declares no options, so it has no option label. ` +
488+
'label() reads a select field\'s option label; read any other field directly ' +
489+
`(record.${field}).`
490+
);
491+
}
492+
431493
/**
432494
* The label a reader reads for `value` of `field`, from the pinned map.
433495
*
@@ -450,13 +512,7 @@ function optionLabelOf(field: string, value: unknown, labels: EvalOptionLabels |
450512
// The field's options first, so a field that declares none faults on EVERY
451513
// row, not only on the rows where it happens to hold a value.
452514
const options = Object.prototype.hasOwnProperty.call(labels, field) ? labels[field] : undefined;
453-
if (options === undefined) {
454-
throw new Error(
455-
`label(record.${field}): \`${field}\` declares no options, so it has no option label. ` +
456-
'label() reads a select field\'s option label; read any other field directly ' +
457-
`(record.${field}).`,
458-
);
459-
}
515+
if (options === undefined) throw new Error(optionlessLabelSentence(field));
460516
if (value === null || value === undefined) return '';
461517
if (typeof value === 'object') {
462518
throw new Error(
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `label(record.<field>)` on a declared field that declares no options is
5+
* refused at author time (#28659).
6+
*
7+
* `label()` (#26608) answers on a field whose definition carries an `options`
8+
* list: the engine builds its label map from exactly those fields, read
9+
* through `declaresOptions`. On any other field the evaluator faults on every
10+
* row and the formula reads null forever. `validateExpression` now gives that
11+
* verdict before the formula is stored, when the caller hands it the object's
12+
* option-bearing fields (`ExprSchemaHint.optionFields`), in the evaluator's
13+
* own words.
14+
*
15+
* Each refusal asserts the code and the named field, not the prose around it;
16+
* the message pin lives with the other code pins in
17+
* `expression-refusal-codes.test.ts`.
18+
*/
19+
import { describe, expect, it } from 'vitest';
20+
import { celEngine } from './cel-engine';
21+
import { declaresOptions, declaresOptionsOnceServed } from './stdlib';
22+
import { validateExpression, type ExprSchemaHint } from './validate';
23+
24+
/** The card's object: `first` is text, `kind` a select with options, `stage` a radio with options. */
25+
const P4: ExprSchemaHint = {
26+
scope: 'record',
27+
objectName: 'qaformula_p4',
28+
fields: ['first', 'kind', 'stage', 'pf'],
29+
fieldTypes: { first: 'text', kind: 'select', stage: 'radio', pf: 'formula' },
30+
traversalHydration: true,
31+
optionFields: ['kind', 'stage'],
32+
};
33+
34+
const codes = (source: string, schema: ExprSchemaHint = P4) => {
35+
const r = validateExpression('value', source, schema);
36+
return r.errors.map((e) => ({ code: e.code, params: e.params }));
37+
};
38+
39+
describe('label(record.FIELD) on a field that declares no options is refused (#28659)', () => {
40+
it('⭐ LIT — the card\'s `label(record.first)` (text) is refused, naming `first`', () => {
41+
const r = validateExpression('value', 'label(record.first)', P4);
42+
expect(r.ok).toBe(false);
43+
expect(r.errors.map((e) => ({ code: e.code, params: e.params }))).toEqual([
44+
{ code: 'label-field-no-options', params: { field: 'first' } },
45+
]);
46+
expect(r.errors[0]!.message).toContain('`first` declares no options');
47+
expect(r.warnings).toEqual([]);
48+
});
49+
50+
it('⭐ LIT — the compound `label(record.kind) + label(record.first)` is refused for `first` alone', () => {
51+
expect(codes('label(record.kind) + label(record.first)')).toEqual([
52+
{ code: 'label-field-no-options', params: { field: 'first' } },
53+
]);
54+
});
55+
56+
it('one refusal per field, however many calls name it', () => {
57+
expect(codes("label(record.first) + ' / ' + label(record.first)")).toEqual([
58+
{ code: 'label-field-no-options', params: { field: 'first' } },
59+
]);
60+
expect(codes("label(record.first) + label(record.pf)")).toEqual([
61+
{ code: 'label-field-no-options', params: { field: 'first' } },
62+
{ code: 'label-field-no-options', params: { field: 'pf' } },
63+
]);
64+
});
65+
66+
it('a call nested anywhere in the expression is judged', () => {
67+
expect(codes("record.kind == 'clean' ? label(record.first) : 'x'")).toEqual([
68+
{ code: 'label-field-no-options', params: { field: 'first' } },
69+
]);
70+
expect(codes('size(label(record.first)) > 0 ? 1 : 0')).toEqual([
71+
{ code: 'label-field-no-options', params: { field: 'first' } },
72+
]);
73+
});
74+
75+
it('CONTROL — label() on fields that declare options is admitted, whatever their type', () => {
76+
expect(validateExpression('value', 'label(record.kind)', P4)).toEqual({ ok: true, errors: [], warnings: [] });
77+
expect(validateExpression('value', "label(record.kind) + ' · ' + label(record.stage)", P4)).toEqual({
78+
ok: true,
79+
errors: [],
80+
warnings: [],
81+
});
82+
});
83+
84+
it('CONTROL — an undeclared field keeps its `unknown-field` refusal and gets no second one', () => {
85+
expect(codes('label(record.nope)')).toEqual([
86+
{ code: 'unknown-field', params: { field: 'nope', objectName: 'qaformula_p4' } },
87+
]);
88+
});
89+
90+
it('CONTROL — an unregistered function keeps its refusal', () => {
91+
expect(codes('sqrt(4)').map((c) => c.code)).toEqual(['cel-unknown-function']);
92+
});
93+
94+
it('CONTROL — reading the optionless field directly is admitted', () => {
95+
expect(validateExpression('value', "record.first + ' · ' + label(record.kind)", P4).ok).toBe(true);
96+
});
97+
98+
it('opt-in: without `optionFields` (or without `fields`) nothing is judged, as before', () => {
99+
const { optionFields: _omit, ...noOptions } = P4;
100+
expect(validateExpression('value', 'label(record.first)', noOptions).ok).toBe(true);
101+
const { fields: _f, ...noFields } = P4;
102+
expect(validateExpression('value', 'label(record.first)', noFields).ok).toBe(true);
103+
expect(validateExpression('value', 'label(record.first)').ok).toBe(true);
104+
});
105+
106+
it('the refusal is the evaluator\'s own fault sentence for the same call', () => {
107+
const run = celEngine.evaluate({ dialect: 'cel', source: 'label(record.first)' }, {
108+
record: { first: 'hello', kind: 'clean' },
109+
optionLabels: { kind: { clean: 'Clean' } },
110+
});
111+
expect(run.ok).toBe(false);
112+
const refused = validateExpression('value', 'label(record.first)', P4).errors[0]!;
113+
if (!run.ok) expect(run.error.message).toBe(refused.message);
114+
});
115+
});
116+
117+
describe('declaresOptions — the one reading of "has options" (#28659)', () => {
118+
it('is true exactly when the definition carries an options list', () => {
119+
expect(declaresOptions({ type: 'select', options: [{ value: 'a', label: 'A' }] })).toBe(true);
120+
expect(declaresOptions({ type: 'multiselect', options: [] })).toBe(true);
121+
expect(declaresOptions({ type: 'text' })).toBe(false);
122+
expect(declaresOptions({ type: 'select' })).toBe(false);
123+
expect(declaresOptions({ type: 'select', options: 'a,b' })).toBe(false);
124+
expect(declaresOptions(null)).toBe(false);
125+
expect(declaresOptions(undefined)).toBe(false);
126+
});
127+
});
128+
129+
describe('declaresOptionsOnceServed — the authored-form reading (#28659)', () => {
130+
it('admits inline options and a named picklist, the two option sources the spec declares', () => {
131+
expect(declaresOptionsOnceServed({ type: 'select', options: [{ value: 'a', label: 'A' }] })).toBe(true);
132+
expect(declaresOptionsOnceServed({ type: 'select', picklist: 'kinds' })).toBe(true);
133+
expect(declaresOptionsOnceServed({ type: 'radio', picklist: 'kinds' })).toBe(true);
134+
expect(declaresOptionsOnceServed({ type: 'select', picklist: '' })).toBe(false);
135+
expect(declaresOptionsOnceServed({ type: 'text' })).toBe(false);
136+
expect(declaresOptionsOnceServed({ type: 'select' })).toBe(false);
137+
expect(declaresOptionsOnceServed(null)).toBe(false);
138+
});
139+
140+
it('a picklist-bound field: refused by the served-form reading as authored, admitted once its list is written on', () => {
141+
const authored = { type: 'select', picklist: 'kinds' };
142+
const served = { ...authored, options: [{ value: 'clean', label: 'Clean' }] };
143+
expect(declaresOptions(authored)).toBe(false);
144+
expect(declaresOptions(served)).toBe(true);
145+
expect(declaresOptionsOnceServed(authored)).toBe(declaresOptions(served));
146+
});
147+
148+
it('CONTROL — a picklist-bound field named in optionFields is admitted by the validator', () => {
149+
const schema: ExprSchemaHint = { ...P4, fields: [...P4.fields!, 'tier'], optionFields: [...P4.optionFields!, 'tier'] };
150+
expect(validateExpression('value', 'label(record.tier)', schema)).toEqual({ ok: true, errors: [], warnings: [] });
151+
});
152+
});

0 commit comments

Comments
 (0)