a2ui-project / a2ui-project/a2ui

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

Open
#2,496 1 comment 0 reactions 1 assignee Claimed by @Varun-S10 View on GitHub
P2 status: first-line-handled status: needs review type: bug
Dominant language
TypeScript
Stars
16.4k
Forks
1.3k
Avg merge
2d 13h
Merged PRs (30d)
134

Description

- [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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.