a2ui-project / a2ui-project/a2ui

[BUG]: Dart and web_core expression parsers disagree on 408 of 2,592 templates

オープン
#2,496 コメント 1 件 リアクション 0 件 担当者 1 名 @Varun-S10 が担当を希望しています GitHub で見る
P2 status: first-line-handled status: needs review type: bug
主要言語
TypeScript
スター
16.4k
フォーク
1.3k
平均マージ
2日 13時間
マージ済み PR(30日)
134

説明

- [x] I have searched the existing issues to make sure this bug has not already been reported.

## Describe the Bug

The client-side expression parser behind `formatString` is implemented once per language, each implementation a close port of the others, and nothing compares them. I ran the Dart (`dart/a2ui_core`) and TypeScript (`renderers/web_core`) parsers over the same 2,592 templates and diffed their output. They disagree on 408 of them.

Grouped by cause:

| divergence | templates | Dart | web_core |
| --- | --- | --- | --- |
| the `null` keyword | 127 | `null` | `""` |
| literal text after an escaped `\${` | 236 | one literal part | split into several |
| adjacent literal parts | 43 | joined | kept separate |
| invalid number literal | 1 | `FormatException` | `NaN` |
| mixed (more than one of the above) | 1 | | |

Two of these are only about how the parts array is split — join adjacent literals on both sides and 272 of the 408 agree. The remaining **137 are genuine disagreements about what a template means**, and 136 of those are the `null` keyword.

### 1. The `null` keyword — the specification contradicts itself

The implementation guide lists it as a literal the parser must recognise:

> **Literals:** Quoted strings (`'...'` or `"..."`), numbers, and keywords (`true`, `false`, `null`).
> — `specification/v0_9/docs/basic_catalog_implementation_guide.md`

But `DynamicValue` in `specification/v0_9/json/common_types.json` is `oneOf` string, number, boolean, array, `DataBinding`, `FunctionCall` — there is no null branch. So a parser cannot both recognise the keyword and produce a value of the declared type.

The two implementations resolved that differently. Dart returns `null`; web_core returns `''` ([expression_parser.ts#L152](https://github.com/a2ui-project/a2ui/blob/676a8999936b17070195230d396c18d14a22d64d/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts#L152)). At the top level it does not show, because both filter empty strings out of the parts array. Inside a function call it does:

```
${check(value: null)}

Dart: {call: check, args: {value: null}, returnType: any}
web_core: {call: check, args: {value: ""}, returnType: any}
```

`required` is specified as "true if `args.value` is strictly not `null`, not `undefined`, not an empty string" — so that particular function agrees either way, but any function that distinguishes absent from empty does not, and neither does an agent reading a reported data model back.

This needs a specification decision rather than a patch, which is why I have not sent one:

1. add `{"type": "null"}` to `DynamicValue` and have web_core return `null`, or
2. drop `null` from the literals list in the guide and have Dart return `''`.

I would suggest (1) — the guide's list reads as deliberate, and collapsing null into the empty string loses information no other literal loses — but that is the maintainers' call.

### 2. Splitting of literal parts

`\${x}` yields `["${x}"]` in Dart and `["${", "x}"]` in web_core; `${f()}${'s'} ` yields `[call, "s "]` versus `[call, "s", " "]`. Concatenated they render the same text, so nothing visible breaks today. Two things make it worth settling anyway:

- `MAX_EXPRESSION_PARTS` (added in #2433) counts parts, so the same template is nearer the cap in one implementation than the other.
- Anything that consumes the parse tree rather than the rendered string sees a different shape per platform.

Dart joins adjacent literals as it scans; web_core never joins. Canonicalising on the joined form is three lines in web_core, and I am happy to send it if that is the shape you want.

## Steps to Reproduce

Feed the same templates to both parsers and compare. Minimal cases:

```dart
// dart/a2ui_core
ExpressionParser().parse(r"${f(a: null)}"); // args: {a: null}
ExpressionParser().parse(r"\${x}"); // ["${x}"]
```

```js
// @a2ui/web_core
new ExpressionParser().parse("${f(a: null)}"); // args: {a: ''}
new ExpressionParser().parse("\\${x}"); // ["${", "x}"]
```

The 2,592-template corpus is 55 hand-written edge cases plus generated ones from a seeded grammar, deduplicated; the comparison normalises number types so `1` and `1.0` are not counted as a difference. Glad to contribute the differential harness as a tool if it is useful.

## Expected Behavior

Every implementation of the parser agrees on what a template means, and the specification says which meaning is the right one.

## Environment Details

- **OS**: macOS
- **Browser/Platform**: Dart 3.12.2, Node.js 25.2.1
- **SDK/Package Name & Version**: `a2ui_core` at main (676a899), `@a2ui/web_core` at main (676a899)
- **Protocol Version**: v0.9

## Additional Context

The invalid-number row is already fixed in the PR that follows this issue: `${1.2.3}` now raises `A2uiExpressionError` in both, instead of a `FormatException` in Dart and a silent `NaN` in web_core.

That PR also adds `conformance/core/expressions.yaml`, a shared suite both clients run, so this class of drift gets caught from now on. It deliberately does not encode either open question above: the harnesses compare with adjacent literals joined, and no case uses the `null` keyword. Once the two are settled, the suite is where the answers should land.

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。