Skip to content

Commit 29cbfcc

Browse files
fix(lint): a flow condition's bare-name type verdict no longer refuses a name a screen field holds, and warns after a node that can bind any name (#30067)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBQYBMyxBQBC2UcSXZG86K9z)_ Fixes #30025 ## 维护者速读 - **改了什么**:流程条件里的裸字段名类型判词不再误拒两类名字。`screen` 节点声明的字段名(恢复时按原名写入变量)不再当作触发对象的字段做类型判断;流程中有能写任意变量名的节点(`script`、`connector_action`、`wait`、`subflow`、`map`、无字段契约的 screen)时,裸名的类型不匹配降为 `type-mismatch-run-may-fail` 警告。`record.x` 的读取照旧报错。null-guard 判词的裸名集合同样剔除 screen 字段名。 - **为什么改**:记录触发流程里 screen 收集了数字 `title`,下游边写 `title > 5`,运行时读到的是收集值、条件能跑,发布门却按文本字段拒绝 `type-mismatch-run-fails`,属误拒。开放绑定节点之后同理:构建期证明不了裸名就是字段。 - **风险与回滚**:放宽发布门,不新增任何拒绝。降级只看流程自身的节点:入口(含运行时发布门的单次写入快照)只改变裸名和 `record.x` 读的是哪条记录,不改变节点是否重绑裸名,所以没有这类节点的记录触发流程,在每道门上的裸名拒绝都不变(#29926、#29959 的发布门钉照旧通过)。回滚即 revert,无数据迁移。 - **先看**:`packages/lint/src/validate-expressions.ts`(`flowBare` 的构建处与旁注)、`packages/formula/src/validate.ts`(`bareNamesUnproven` 与同源的 record 根回查)、`packages/lint/src/flow-variable-scope.ts`(`collectScreenFieldNames`)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/publish-type-mismatch-fail-closed.test.ts`,预期 12 条全过:screen 字段持有的裸 `name > 5` 发布无判词;`script` 节点之后的裸 `name > 5` 发布带警告;两者后面的 `record.name > 5` 仍被拒;#29926/#29959 的原有拒绝不变。 - **需要你做的**:无,席位审阅并落地。 Clause-②: yes (widening) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) no longer refuses, on a record-triggered flow (or a time-relative sweep of its own object), a `type-mismatch-run-fails` on a BARE condition name that a `screen` node's declared field holds (no verdict now), nor on a bare name in a flow with a node that can bind a name no reader sees (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no input contract), which is now the `type-mismatch-run-may-fail` warning. A `record.<field>` / `previous.<field>` read keeps its refusal, and nothing new is refused. The null-guard verdict's bare names also leave a screen's field names out. Added beside them: the optional `bareNamesUnproven` key of `@objectstack/formula`'s `ExprSchemaHint`, and `collectScreenFieldNames` in `@objectstack/lint`'s flow-variable-scope module. Metadata-protocol: none Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names the screen field and the open binder); `skills/**` and the MCP skill text teach no bare flow-condition name a screen or an open binder holds, so no skill or eval moves. ## What changes Measured on `main` at `c23501b2`. Two kinds of name a bare flow-condition read can hold instead of the trigger record's field were still typed as the field, so a record-triggered flow was refused `type-mismatch-run-fails` on a condition that runs: | bare name | what the run binds (measured) | before | now | |:--|:--|:--|:--| | a `screen` node's declared field `name` | `applyResumeSignal` (`service-automation/src/engine.ts`) lands the resume bag's `variables` under their plain names, over the flattened field | typed as the field: **error** `type-mismatch-run-fails` | not typed as the field (joins `ExprSchemaHint.flowVariables`); no verdict | | any bare name, in a flow with a node that can bind a name no reader sees (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no input contract; `flowCelRootScope`'s `CLOSED_BINDER_NODE_TYPES`) | the node can write the live variable map or fold any resume bag | **error** `type-mismatch-run-fails` | **warning** `type-mismatch-run-may-fail` | | `record.<field>` / `previous.<field>` in either flow | the field | error | error (unchanged) | **Screen fields (`@objectstack/lint`).** New `collectScreenFieldNames(graphs)` in `flow-variable-scope.ts` reads every `screen` node's `config.fields[].name` across every graph. The flow walk unions it into the names it hands the type check (`flowVariables`), and subtracts it from the null-guard twin's `nullGuardBare`. `collectFlowVariableNames` is unchanged, so the #14089 shadowing warning and its other consumers are not moved. **Open binders (`@objectstack/formula` + lint).** New optional `ExprSchemaHint.bareNamesUnproven` (flattened scope only). In `typeSoundnessIssue`, a `type-mismatch` on a bare name (no `record`/`previous` root) under a declared outcome takes no outcome when the key is set, so it is the existing run-may-fail warning. If a `record.<field>` mismatch sits in the same source behind the bare one (`name > 5 && record.title > 5`), `firstTypeMismatch` is asked again with every bare name left `dyn`, and that record-root mismatch keeps the error. Date verdicts are untouched. **Which predicate (a measured departure from the order's literal wording, agreed with the seat).** The order said to use `rootScope.provable`, the null-guard twin's predicate. Measured: at the runtime publish gate `rootScope` is built with `perWriteSnapshotEntrance()`, which opens EVERY flow write, so the literal reading would have dropped every bare-name refusal on every record-triggered flow at the Studio/package publish door to a warning; `publish-type-mismatch-fail-closed.test.ts` goes red on the #29926 start pin and the #29959 decision pin (ablation A5 below). The flow walk now asks the same judge, `flowCelRootScope(flow, graphs, objectName, fieldIndex)`, with no entrance: only a NODE that can bind a name stands the bare verdict down. An entrance (and the per-write snapshot, which sees none) changes which record both the bare name and `record.<field>` read, not whether a node rebinds the bare name, so it moves neither. The null-guard twin keeps standing its bare names down on the whole `rootScope`, as before. A code comment beside the call says so. **Ledger.** `faultOutcomeOf`'s ledger docblock gains the screen field in the variable row and a row for the open-binder stand-down; `validate-null-guards.ts` point 2 names the screen fields and the shared judge. **Not done (optional item).** The engine's registration advisory pass (`flow-registration-checks.ts`) still passes no `flowVariables`: `@objectstack/service-automation` does not depend on `@objectstack/lint` (where `collectFlowVariableNames` / `collectScreenFieldNames` live), and adding that dependency is outside this card. Its line is a log, never a refusal. ## Tests - `packages/formula/src/validate.test.ts`: with `bareNamesUnproven` and `run-fails`, bare `name > 5` / `is_active > 0` / `name * 2 > 1` → one `type-mismatch-run-may-fail` warning; `record.name > 5`, `previous.is_active > 0` and `name > 5 && record.title > 5` → `type-mismatch-run-fails` naming the record read; controls: key absent → the error; `flowVariables` still untypes; a `record`-scoped `fail-closed` site ignores the key. - `packages/lint/src/validate-expressions.test.ts`, new `describe` "a bare name a screen or an open binder can hold (#30025)": record-triggered flow, screen field `title: number` before a `title > 5` edge and a `title > 5` decision branch → no type verdict, only the `record.title > 5` branch refused; the same after a `script` node and after an `object-form` screen → two warnings, one error; the null-guard twin: nullable `budget` with a screen collecting `budget` → no nullable refusal, control screen collecting `note` → refused. The #5017 receiver meta-test tables the `flowBare` / `screenFields` plumbing receivers. - `packages/objectql/src/publish-type-mismatch-fail-closed.test.ts`, new top-level `describe` in the existing harness, through the real `saveMetaItem` (draft) + `publishPackageDrafts` door: screen-held bare `name > 5` publishes with no type advisory; bare `name > 5` after a `script` node publishes with the run-may-fail warning; `record.name > 5` after either node is refused. The existing #29926 / #29959 refusals in the same file are the publish-door pins that the bare refusal still holds without such a node. Ablations (each restored, tree clean after): | | put back | red | |:--|:--|:--| | A1 | screen fields out of `flowBare.variables` | lint "does not type a bare name a screen field holds…" | | A2 | `unproven: false` | lint "warns, does not refuse, a bare-name mismatch after a node that can bind any name" | | A3 | formula: no record-root re-ask (`outcome = undefined` always) | formula "drops a bare-name mismatch…" (`name > 5 && record.title > 5`) | | A4 | screen fields back in `nullGuardBare` | lint "leaves the null-guard twin silent…" | | A5 | literal `unproven: !rootScope.provable` | objectql #29926 "refuses the comparison in a record-triggered flow condition…" and #29959 "refuses a `name > 5` decision branch…" | | A6 | `unproven: false` at the publish door | objectql "publishes a bare `name > 5` after a script node with the run-may-fail warning" | ## Verification All at head `71da71df`, under `os-verify-lock` (shared box). Base `c23501b2`; `origin/main` is 3 commits ahead, none on the touched files, and `git merge-tree` reports no conflict. ``` build pnpm --filter '@objectstack/lint^...' --filter @objectstack/lint build exit 0 build pnpm --filter '@objectstack/objectql^...' build exit 0 typecheck formula, lint, objectql exit 0 formula test Test Files 64 passed (64) Tests 1639 passed (1639) lint test Test Files 193 passed (193) Tests 7977 passed (7977) objectql test Test Files 547 passed (547) Tests 10836 passed (10836) mcp src/mcp-validate-expression.test.ts Tests 35 passed (35) (formula hint parity) pnpm lint exit 0 ``` `node scripts/pm/dispatch-gates.mjs --commands`: 89 derived families, each run, `CMD :: exit N` recorded, reconciled with `--ran`: **89 derived, 88 run, 1 NOT-MEASURED, 0 UNRUN**. 88 exit 0 (three of them, `check-plugin-teardown-shape --self-test`, `check:skill-examples` and `check:lean-entry-closure`, first exited 3 on a missing prerequisite and pass after `git fetch --unshallow` and building objectql / client-react). NOT MEASURED: `check:dual-build-cjs-loads`, reason: it reads every package's dist (70 missing) and needs a full `pnpm build`; CI's run is the measurement. ## Acceptance notes - Flow-scoped, like `collectFlowVariableNames`: a screen field name is excluded at every condition of the flow, the start condition included, although a start condition runs before any screen. That is a missed verdict (the safe direction), the same trade #29959 made for node ids, for the reason `collectFlowVariableNames`' docblock gives (one variable map per run; no traversal-order verdicts). - The #14089 shadowing warning (`warnShadowedFieldReads`) still names declared variables and node ids only, not screen fields. Not judged here. - A bare `date - date + 1` (`date-arithmetic`) after an open binder is still an error: the card covers the type-mismatch verdict, and that one is not an outcome verdict. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzicfrt7Yr8FaVdDsXD <!-- signed: domain:server#6 · os-support-ai · session_01WGufzicfrt7Yr8FaVdDsXD --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d79eecf commit 29cbfcc

9 files changed

Lines changed: 305 additions & 24 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
fix(lint): a flow condition's bare-name type verdict no longer refuses a name a `screen` field holds, and stands down to the warning after a node that can bind any name (#30025)
7+
8+
Clause-②: yes (widening) — the author-time gate (`os build`, `os validate`, the package publish and the flow publish door) no longer refuses, on a record-triggered flow (or a time-relative sweep of its own object), a `type-mismatch-run-fails` on a BARE condition name that a `screen` node's declared field holds (no verdict now), nor on a bare name in a flow with a node that can bind a name no reader sees (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no input contract), which is now the `type-mismatch-run-may-fail` warning. A `record.<field>` / `previous.<field>` read keeps its refusal, and nothing new is refused. The null-guard verdict's bare names also leave a screen's field names out. Added beside them: the optional `bareNamesUnproven` key of `@objectstack/formula`'s `ExprSchemaHint`, and `collectScreenFieldNames` in `@objectstack/lint`'s flow-variable-scope module.
9+
Metadata-protocol: none
10+
Skills-docs: updated here — `content/docs/data-modeling/formulas.mdx` (the type-soundness row names the screen field and the open binder); `skills/**` and the MCP skill text teach no bare flow-condition name a screen or an open binder holds, so no skill or eval moves.
11+
12+
- **Before.** A record-triggered flow whose `screen` node collects `title` (a number) and whose edge after it reads `title > 5`, with `title` a text field on the trigger object, was refused `type-mismatch-run-fails`: the bare name was typed as the field. The run binds the collected number there (a resume lands a screen's values under their plain names), so the condition runs. The same refusal hit a bare name after a `script`, `connector_action`, `wait`, `subflow` or `map` node, or a screen with no input contract, any of which can bind the name to a value nothing can see at build.
13+
- **Now.** A screen's declared field names join the names a bare read holds instead of the field, so they are not typed as it (the null-guard verdict leaves them out too). In a flow with a node that can bind any name, a bare-name mismatch is the `type-mismatch-run-may-fail` warning, as the null-guard verdict already stands its bare names down there; `record.<field>` is still refused. The stand-down reads the flow's nodes only: an entrance (and the publish door's per-write snapshot, which sees none) changes which record both the bare name and `record.<field>` read, not whether a node rebinds the bare name, so a bare-name refusal on a record-triggered flow with no such node is unchanged at every door.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ come at two severities: **errors** fail the build; **warnings** are advisory
242242
| **Qualify field references** — a formula / validation / predicate binds only the `record` namespace, so a bare `amount` resolves to nothing and evaluates to `null`. Write `record.amount`. | `amount > 100` → `record.amount > 100` | error |
243243
| **Field must exist** — a typo'd `record.<field>` is flagged with a did-you-mean. | `record.amont` → `record.amount` | error |
244244
| **Unknown function** — a misspelled or nonexistent stdlib call. | `isBlnk(record.x)` | error |
245-
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime unless the text is numeric. In a formula it evaluates to `null`. Where a fault does something else it is an error, worded for what happens: a validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen` or a hook `condition` refuses the write; a record-triggered flow's start, edge or `decision` branch condition fails the run (the write that fired it still lands), whether it reads the field bare or as `record.<field>`, and so does a time-relative flow's that sweeps the same object; a bare name that a declared flow variable holds reads the variable, so it is not judged as the field; an advisory (`severity: 'warning'` / `'info'`) validation rule is skipped, so it never gives its advisory. Store it as a number field, compare text with text, or convert explicitly (`size(record.name)` for the length; `double(record.name)`, which itself faults on text that is not a number). | `record.title * 2`; `record.name > 5` in a validation rule → `size(record.name) > 5` | warning in a formula or a display predicate, and in a condition of any other flow, where the warning says the run fails if the condition faults (what such a flow binds under the name depends on how it is started); error at the sites named |
245+
| **Type soundness** — a text or boolean field used with an arithmetic (`+ - * / %`) or ordering (`< > <= >=`) operator against a number faults at runtime unless the text is numeric. In a formula it evaluates to `null`. Where a fault does something else it is an error, worded for what happens: a validation rule, a field `requiredWhen` / `readonlyWhen`, an option `visibleWhen` or a hook `condition` refuses the write; a record-triggered flow's start, edge or `decision` branch condition fails the run (the write that fired it still lands), whether it reads the field bare or as `record.<field>`, and so does a time-relative flow's that sweeps the same object; a bare name that a declared flow variable or a `screen` field holds reads that value, so it is not judged as the field, and a bare name after a node that can bind any name (`script`, `connector_action`, `wait`, `subflow`, `map`, a screen with no fields) gets the warning instead, while its `record.<field>` read is still refused; an advisory (`severity: 'warning'` / `'info'`) validation rule is skipped, so it never gives its advisory. Store it as a number field, compare text with text, or convert explicitly (`size(record.name)` for the length; `double(record.name)`, which itself faults on text that is not a number). | `record.title * 2`; `record.name > 5` in a validation rule → `size(record.name) > 5` | warning in a formula or a display predicate, and in a condition of any other flow, where the warning says the run fails if the condition faults (what such a flow binds under the name depends on how it is started); error at the sites named |
246246
| **Date arithmetic** — a `date` / `datetime` field used with `+` / `-` against a number (a `YYYY-MM-DD` value is a string at runtime, so this always faulted to `null`). Use the date helpers instead. | `record.end_date - record.start_date + 1` → `daysBetween(record.start_date, record.end_date) + 1`; `today() + 30` → `daysFromNow(30)`; `record.date + n` → `addDays(record.date, n)` | error (#3306) |
247247
| **Date subtraction** — the runtime holds a `date` / `datetime` field as its text (`YYYY-MM-DD`, or an ISO-8601 instant), so subtracting two of them faults and the formula reads `null`; a timestamp minus one computes a duration, which a field reads as `{}`. Use `daysBetween`. A condition may still compare a duration (`today() - record.start_date > duration("72h")`). | `record.end_date - record.start_date` → `daysBetween(record.start_date, record.end_date)`; `today() - record.start_date` → `daysBetween(record.start_date, today())` | error |
248248

‎packages/formula/src/validate.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,39 @@ describe('validateExpression (ADR-0032)', () => {
851851
.toEqual(['type-mismatch']);
852852
});
853853

854+
// [#30025] Where the run can bind a bare name no reader sees (an open
855+
// binder node, a contract-less screen), a bare-name mismatch is not proven
856+
// to be the field's: it takes no declared outcome and is the run-may-fail
857+
// warning. A `record.<field>` read still is the field and keeps the error,
858+
// also when a bare mismatch comes first in the same source.
859+
it('drops a bare-name mismatch to the run-may-fail warning where bare names are unproven, keeping record.<field> refused (#30025)', () => {
860+
const flat = { ...schema, scope: 'flattened' as const, faultOutcome: 'run-fails' as const, bareNamesUnproven: true };
861+
for (const source of ['name > 5', 'is_active > 0', 'name * 2 > 1']) {
862+
const r = validateExpression('predicate', source, flat);
863+
expect(r.errors, source).toEqual([]);
864+
expect(r.warnings.map((w) => w.code), source).toEqual(['type-mismatch-run-may-fail']);
865+
expect(r.warnings[0].message, source).toContain('which this check cannot prove here');
866+
}
867+
for (const [source, reference] of [
868+
['record.name > 5', 'record.name'],
869+
['previous.is_active > 0', 'previous.is_active'],
870+
['name > 5 && record.title > 5', 'record.title'],
871+
] as const) {
872+
const r = validateExpression('predicate', source, flat);
873+
expect(r.warnings, source).toEqual([]);
874+
expect(r.errors.map((e) => [e.code, (e.params as { reference?: string }).reference]), source)
875+
.toEqual([['type-mismatch-run-fails', reference]]);
876+
}
877+
// Controls: the key absent is the error as before; a bare name the run
878+
// binds as a variable is still untyped; a `record`-scoped site ignores it.
879+
expect(validateExpression('predicate', 'name > 5', { ...flat, bareNamesUnproven: undefined }).errors.map((e) => e.code))
880+
.toEqual(['type-mismatch-run-fails']);
881+
const shadowed = validateExpression('predicate', 'name > 5', { ...flat, flowVariables: ['name'] });
882+
expect([shadowed.errors, shadowed.warnings]).toEqual([[], []]);
883+
expect(validateExpression('predicate', 'record.name > 5', { ...schema, faultOutcome: 'fail-closed' as const, bareNamesUnproven: true }).errors.map((e) => e.code))
884+
.toEqual(['type-mismatch-fail-closed']);
885+
});
886+
854887
// [#29959] With no declared outcome a flattened condition is a flow's
855888
// that is not proven to bind the field: a warning, but one saying the run
856889
// fails on the fault, since a flow condition never reads null.

‎packages/formula/src/validate.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,18 @@ export interface ExprSchemaHint {
267267
* typed. Absent ⇒ every bare field name is typed, exactly as before this key.
268268
*/
269269
flowVariables?: readonly string[];
270+
/**
271+
* [#30025] `'flattened'` scope only: the flow's run can bind a name no static
272+
* reader sees — a node that hands out the live variable map or folds a
273+
* resume bag in (`script`, `connector_action`, `wait`, `subflow`, `map`, a
274+
* screen with no input contract; `@objectstack/lint`'s `flowCelRootScope`
275+
* over the flow's nodes, `provable: false`). A bare name
276+
* is then not proven to hold the field, so a bare-name `type-mismatch` takes
277+
* no declared {@link faultOutcome}: it is the `type-mismatch-run-may-fail`
278+
* warning. A `record.<name>` / `previous.<name>` read still reads the field
279+
* and keeps the outcome. Absent ⇒ every verdict is exactly as before this key.
280+
*/
281+
bareNamesUnproven?: boolean;
270282
}
271283

272284
/**
@@ -414,14 +426,29 @@ function typeSoundnessIssue(
414426
role: 'predicate' | 'value',
415427
faultOutcome: ExprSchemaHint['faultOutcome'],
416428
flowVariables?: readonly string[],
429+
bareNamesUnproven?: boolean,
417430
): { issue: ExprValidationError; severity: 'error' | 'warning' } | null {
418431
const celFieldTypes = toCelFieldTypes(fieldTypes);
419432
// [#29959] A bare name a flow variable holds is not the field (see
420433
// `ExprSchemaHint.flowVariables`), for every bare-name verdict here.
421434
const shadowed = scope === 'flattened' && flowVariables && flowVariables.length > 0
422435
? new Set(flowVariables)
423436
: undefined;
424-
const mismatch = firstTypeMismatch(source, celFieldTypes, scope, shadowed);
437+
let mismatch = firstTypeMismatch(source, celFieldTypes, scope, shadowed);
438+
// [#30025] Where the run can bind a bare name no reader sees
439+
// (`ExprSchemaHint.bareNamesUnproven`), a bare-name mismatch is not proven to
440+
// be the field's, so it takes no declared outcome. A `record.<field>` /
441+
// `previous.<field>` mismatch in the same source still does: with every bare
442+
// name left `dyn`, only the record-root pass can answer, and its answer wins.
443+
let outcome = faultOutcome;
444+
if (
445+
mismatch && scope === 'flattened' && bareNamesUnproven && faultOutcome !== undefined
446+
&& mismatch.category === 'type-mismatch' && mismatch.root === undefined
447+
) {
448+
const rootRead = firstTypeMismatch(source, celFieldTypes, scope, new Set(Object.keys(celFieldTypes)));
449+
if (rootRead && rootRead.root !== undefined) mismatch = rootRead;
450+
else outcome = undefined;
451+
}
425452
if (!mismatch) {
426453
const bareCelTypes = shadowed
427454
? Object.fromEntries(Object.entries(celFieldTypes).filter(([name]) => !shadowed.has(name)))
@@ -472,10 +499,10 @@ function typeSoundnessIssue(
472499
`type mismatch \`${mismatch.operands}\` — ${subject} but is used with \`${mismatch.operator}\` ` +
473500
`against a number. CEL has no such operation, so wherever the value is not numeric text ` +
474501
`(an empty value included) the expression faults`;
475-
if (faultOutcome !== undefined) {
502+
if (outcome !== undefined) {
476503
// [#29926] One code per outcome: a code names ONE message template
477504
// (`expression-refusal.ts`), and the three outcomes are three sentences.
478-
const arm = TYPE_MISMATCH_FAULT_ARMS[faultOutcome];
505+
const arm = TYPE_MISMATCH_FAULT_ARMS[outcome];
479506
return {
480507
severity: 'error',
481508
issue: refusal(arm.code, params, source, `${faults}, and here ${arm.clause} ${convert}`),
@@ -1405,7 +1432,8 @@ export function validateExpression(
14051432
// date-field arithmetic (error). Flow variables stay `dyn` (never flagged);
14061433
// equality/ordering of a date is runtime-safe (never flagged).
14071434
if (schema.fieldTypes) {
1408-
const r = typeSoundnessIssue(source, schema.fieldTypes, 'flattened', role, schema.faultOutcome, schema.flowVariables);
1435+
const r = typeSoundnessIssue(source, schema.fieldTypes, 'flattened', role, schema.faultOutcome, schema.flowVariables,
1436+
schema.bareNamesUnproven);
14091437
if (r) (r.severity === 'error' ? errors : warnings).push(r.issue);
14101438
}
14111439
}

‎packages/lint/src/flow-variable-scope.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ export const VARIABLE_NAME_CONFIG_KEYS = ['iteratorVariable', 'indexVariable', '
131131
*/
132132
const ASSIGNMENT_NODE_TYPE = 'assignment';
133133

134+
/** The node type whose resume binds its declared fields' names ({@link collectScreenFieldNames}). */
135+
const SCREEN_NODE_TYPE = 'screen';
136+
134137
/**
135138
* The keys an `assignments` ARRAY entry may name its target with — the three
136139
* `logic-nodes.ts` reads, in its precedence order. Exported so the guard can
@@ -248,6 +251,36 @@ export function collectFlowVariableNames(
248251
return names;
249252
}
250253

254+
/**
255+
* [#30025] Every `screen` node's field `name`, across every graph of the flow:
256+
* the names a resume binds. A screen's collected inputs land under their plain
257+
* names (`applyResumeSignal`, the resume bag's `variables`), over whatever the
258+
* run flattened there, so downstream of the screen a bare read of one holds the
259+
* collected value, not the record's field. {@link collectFlowVariableNames}
260+
* does not collect them (its consumers predate the screen row), so a caller
261+
* that needs every name a bare read can hold instead of the field unions both.
262+
*
263+
* Only the names a screen DECLARES: a screen with no input contract folds any
264+
* bag, which no reader can enumerate — `flowCelRootScope` reports such a flow
265+
* as not provable, and that is the caller's other half.
266+
*/
267+
export function collectScreenFieldNames(graphs: readonly FlowGraphLike[]): ReadonlySet<string> {
268+
const names = new Set<string>();
269+
for (const graph of graphs) {
270+
for (const flowNode of recordsOf(graph.nodes)) {
271+
if (flowNode.type !== SCREEN_NODE_TYPE) continue;
272+
const rawConfig = flowNode.config;
273+
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) continue;
274+
const fields = (rawConfig as AnyRec).fields;
275+
if (!Array.isArray(fields)) continue;
276+
for (const field of recordsOf(fields)) {
277+
if (typeof field.name === 'string' && field.name) names.add(field.name);
278+
}
279+
}
280+
}
281+
return names;
282+
}
283+
251284
/**
252285
* Upper bound on the bare-root enumeration below. A predicate reaching for more
253286
* distinct undeclared roots than this is pathological, and the loop must

0 commit comments

Comments
 (0)