Skip to content

test(conformance): add a cross-language conformance suite for the expression parser - #2497

Open
diegolopezrm wants to merge 7 commits into
a2ui-project:mainfrom
diegolopezrm:feat-expression-parser-conformance
Open

test(conformance): add a cross-language conformance suite for the expression parser#2497
diegolopezrm wants to merge 7 commits into
a2ui-project:mainfrom
diegolopezrm:feat-expression-parser-conformance

Conversation

@diegolopezrm

Copy link
Copy Markdown
Contributor

Description

Addresses part of #2496.

The client-side expression parser behind formatString exists once per language, each implementation a close port of the others, and nothing compares them. Running the Dart and TypeScript parsers over the same 2,592 templates, they disagree on 408 — 137 of those on what a template actually means rather than on how the parts array is split.

This PR does two things: it fixes the one disagreement that is unambiguously a bug in both, and it adds a conformance suite so the next drift is caught by CI instead of by someone diffing two parsers by hand.

1. Invalid number literals

A literal the scanner accepts but neither language can parse — ${1.2.3}, ${1..2} — ended somewhere different in each:

before after
Dart FormatException from num.parse A2uiExpressionError
web_core Number() returns NaN, handed back as a parsed value A2uiExpressionError

Both were wrong in their own way. FormatException is outside the A2uiError hierarchy consumers catch, and one avoid_catching_errors discourages catching at all; NaN is not a JSON value and not a member of DynamicValue, so web_core was placing a value in the parse tree that the protocol cannot express, silently. Every other parse failure in both raises A2uiExpressionError, and now so does this one.

2. conformance/core/expressions.yaml

A shared suite of 30 cases — literals, data bindings, function calls, nested interpolation, escaped markers, whitespace, and the five parse errors — expressed once and run by both clients. It follows the existing structure in conformance/, and conformance/tests/test_conformance_yaml.py validates it against conformance_schema.json with no change to that test.

Supporting changes:

  • conformance_schema.json: adds the parse_expression_template action and its case shape, alongside the existing ones.
  • conformance/README.md: documents the suite, the two client harnesses, and how to write cases for the new action.
  • Dart harness: dart/a2ui_core/test/conformance/expressions_conformance_test.dart.
  • TypeScript harness: renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.conformance.test.ts.

Both harnesses locate the suite by walking up from the test, so neither needs a configured path, and both map the suite's language-agnostic error categories onto their own types (ParseErrorA2uiExpressionError). js-yaml comes in as a web_core dev dependency, matching the approach in #2182.

As far as I can tell these are the first conformance suites run by the client implementations — conformance/ is currently consumed by the Python and Kotlin agent SDKs — so the same pattern is available to Swift and Kotlin clients next.

What the suite deliberately does not decide

Two of the divergences in #2496 are open questions rather than bugs, and this PR does not quietly settle either:

  • Literal splitting. The harnesses join adjacent literal parts before comparing, so a case fixes what a template means, not how an implementation splits the literal text around its values. Both implementations conform today despite splitting differently. If you would rather canonicalise the split, the joining step is where that decision goes.
  • The null keyword. No case uses it. The guide lists null as a literal the parser must recognise while DynamicValue has no null branch, so any expectation I wrote would be me picking a side in a specification contradiction. [BUG]: Dart and web_core expression parsers disagree on 408 of 2,592 templates #2496 lays out the options.

Verification

  • conformance: pytest conformance/tests/test_conformance_yaml.py — 9 passed, the new suite validating with the rest.
  • Dart: dart test in dart/a2ui_core — 104 passing (74 before, plus the 30 conformance cases). dart analyze reports the same two pre-existing unnecessary_library_directive infos as main and nothing new; dart format clean.
  • TypeScript: node --test "dist/**/*.test.js" in renderers/web_core — 447 passing, 0 failing. eslint reports no new problems (the two no-explicit-any warnings are pre-existing); prettier --check clean on everything touched.
  • Differential run, before and after, over the same 2,592 templates: semantic disagreements went from 137 to 136, and every remaining one is the null keyword left to [BUG]: Dart and web_core expression parsers disagree on 408 of 2,592 templates #2496.

A note on the checklist below

As in #2491 and #2494, I have not touched dart/a2ui_core/CHANGELOG.md; #2439 already adds a ## 0.2.0 section there and a competing one would only give that PR a conflict. renderers/web_core/CHANGELOG.md I did leave alone deliberately as well — the web_core change here is test-only plus the number-literal fix, and I would rather add the entry in the wording you prefer. Happy to add both on request.

Pre-launch Checklist

One time:

For this PR:

  • I have updated the relevant CHANGELOG.md file. (see the note above)
  • I updated/added relevant documentation. (conformance/README.md)
  • My code changes (if any) have tests.
  • If my branch is on a fork, I have verified that scripts/e2e_test.sh passes. (both package suites and the conformance YAML validation pass)

…rror

A number literal the scanner accepts but neither language can parse, such
as `${1.2.3}`, left each implementation somewhere different: Dart threw a
`FormatException` from `num.parse` — an error outside the `A2uiError`
hierarchy, which `avoid_catching_errors` discourages catching — while
TypeScript's `Number()` returned NaN and handed it back as a parsed
value, silently placing a non-JSON value in the parse tree.

Both now throw `A2uiExpressionError`, like every other parse failure.
…parser

The expression parser behind `formatString` is implemented once per
client language, each a port of the others, and nothing compares them.
This adds `core/expressions.yaml`: 30 cases covering literals, data
bindings, function calls, nested interpolation, escaped markers and the
parse errors, expressed once for every implementation to run.

Cases are compared with adjacent literal parts joined, so a case fixes
what a template means rather than how an implementation splits the
literal text around its values. Errors use the suite's language-agnostic
categories, with `ParseError` mapping to each SDK's expression error.
Adds the Dart harness for `core/expressions.yaml`. It locates the suite
by walking up from the working directory, so it needs no configured
path, and maps the suite's error categories onto `A2uiExpressionError`.

30 cases, all passing.
Adds the TypeScript harness for `core/expressions.yaml`, following the
same shape as the Dart one: walk up to find the suite, map `ParseError`
onto `A2uiExpressionError`, compare with adjacent literals joined.

Pulls in js-yaml as a dev dependency to read the suite.

30 cases, all passing.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a shared conformance test suite for the client-side expression parser (core/expressions.yaml) along with corresponding test harnesses in both Dart and TypeScript. It also updates the expression parsers in both languages to handle invalid number literals. The review feedback highlights potential cross-language and platform-specific discrepancies in how numbers are parsed (e.g., differences between Dart VM, Dart Web, and JavaScript/TypeScript). To ensure strict behavioral parity, the reviewer suggests validating number literals against a standardized regular expression before conversion in both the TypeScript and Dart implementations.

Comment on lines +247 to +252
const text = scanner.input.substring(start, scanner.pos);
const value = Number(text);
if (!Number.isFinite(value)) {
throw new A2uiExpressionError(`Invalid number literal: '${text}'`);
}
return value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Number(text) and Number.isFinite(value) can lead to subtle cross-language and platform-specific discrepancies. For example, Number("1.") and Number(".5") are parsed as valid numbers in JavaScript/TypeScript, whereas in Dart (especially on the VM), num.tryParse("1.") and num.tryParse(".5") return null and will throw an error. Additionally, Number("") evaluates to 0 (which is finite), potentially allowing empty number literals to pass silently if the scanner doesn't advance.

To ensure strict 1:1 behavioral parity across all client implementations and platforms, we should validate the parsed number literal against a standardized decimal/integer regular expression (e.g., /^\d+(?:\.\d+)?$/) before converting it.

Suggested change
const text = scanner.input.substring(start, scanner.pos);
const value = Number(text);
if (!Number.isFinite(value)) {
throw new A2uiExpressionError(`Invalid number literal: '${text}'`);
}
return value;
const text = scanner.input.substring(start, scanner.pos);
if (!/^\d+(?:\.\d+)?$/.test(text)) {
throw new A2uiExpressionError("Invalid number literal: '" + text + "'");
}
return Number(text);
References
  1. Maintain strict 1:1 structural and behavioral parity across different client implementations (such as Dart and TypeScript) to prevent subtle behavioral drift.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted the regex in both implementations, though the premise needs a correction.

num.tryParse("1.") returns 1.0 on the Dart VM, not null, and .5 returns 0.5. I also compiled the same code with dart2js and ran it under Node to check the VM/web split specifically — the two agree on every input tried:

input Dart VM dart2js Number()
1. 1.0 1 1
.5 0.5 0.5 0.5
"" null null 0
1.2.3 null null NaN

So the drift you describe is not there today. .5 and "" also cannot reach this function — the scanner only enters it on a digit, and stops at anything that is not a digit or a point.

The underlying point stands anyway, and it is the better design: the accepted shape should be stated, not inherited from whichever number parser the platform ships. Both now use the grammar you suggested. The visible consequence is that ${1.} moves from accepted-by-accident to rejected in both, so I added a conformance case for it in core/expressions.yaml — that way the answer lives in the shared suite instead of in two platform parsers, and changing our minds means editing one case.

Comment on lines +238 to +243
final String text = scanner.input.substring(start, scanner.pos);
final num? value = num.tryParse(text);
if (value == null) {
throw A2uiExpressionError("Invalid number literal: '$text'");
}
return value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using num.tryParse(text) can lead to platform-specific discrepancies between Dart VM and Dart Web (where it compiles to JavaScript's parseFloat/Number and might accept trailing or leading decimals like 1. or .5 that Dart VM rejects).

To guarantee strict 1:1 behavioral parity across all platforms and client implementations, we should validate the parsed number literal against a standardized decimal/integer regular expression before parsing it.

Suggested change
final String text = scanner.input.substring(start, scanner.pos);
final num? value = num.tryParse(text);
if (value == null) {
throw A2uiExpressionError("Invalid number literal: '$text'");
}
return value;
final String text = scanner.input.substring(start, scanner.pos);
if (!RegExp(r"^\d+(?:\.\d+)?$").hasMatch(text)) {
throw A2uiExpressionError("Invalid number literal: '$text'");
}
return num.parse(text);
References
  1. Maintain strict 1:1 structural and behavioral parity across different client implementations (such as Dart and TypeScript) to prevent subtle behavioral drift.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change applied here; see the reply on the TypeScript side for the measurements, including a dart2js run that shows the VM and web behave the same on these inputs.

Both implementations delegated the shape of a number literal to their
platform's parser — `num.parse` and `Number()` — which agree today but
need not: each accepts inputs the grammar never intended, `Number('')`
being 0 among them, and neither is specified anywhere.

The accepted shape is now written out as digits, optionally followed by a
decimal point and more digits, identically in both. `${1.}` moves from
accepted-by-accident (1.0 in Dart, 1 in TypeScript) to rejected in both,
and a conformance case pins that so the answer lives in the suite rather
than in two platform number parsers.
Ran the suite's inputs through the Dart, TypeScript, Python and Swift
parsers. Two cases turned out to encode a choice rather than the shared
behaviour, and both are fixed here.

The number literal grammar rejected a trailing point (`${1.}`). All four
implementations accept it today, so this had turned a unanimous
behaviour into a 2-2 split — the grammar now reads digits, an optional
point, and optional further digits, keeping `${1.2.3}` rejected as all
four already do.

The empty template pinned `[""]`, which only Dart and TypeScript produce;
Python and Swift return `[]`. An empty literal carries no content either
way, so the harnesses now drop empty literals alongside joining adjacent
ones, and the case no longer pins a splitting choice.

All 31 cases now pass in all four implementations.
@diegolopezrm

Copy link
Copy Markdown
Contributor Author

Update: I ran the suite's inputs through all four expression parser implementations in the repository — dart/a2ui_core, renderers/web_core, agent_sdks/python/a2ui_core and swift/core — rather than only the two that ship a harness here. All 31 cases now pass in all four.

Two of them did not, and both were mine rather than theirs.

The number literal grammar was wrong. I had it reject a trailing point (${1.}) after the review comment above. Measured across the four, every implementation accepts it today:

${1.} ${1.2.3}
Dart 1.0 error
TypeScript 1 NaN (fixed in this PR)
Python 1.0 error
Swift 1 error

So that change turned a unanimous behaviour into a 2-2 split, which is the opposite of what this PR is for. The grammar now reads digits, an optional point, and optional further digits — still stated in each parser rather than delegated to the platform's number parser, still rejecting ${1.2.3} as three of the four already did, and no longer inventing a divergence.

The empty-template case pinned a splitting choice. It expected [""], which is what Dart and TypeScript produce; Python and Swift return []. An empty literal carries no content either way, so the harnesses now drop empty literals alongside joining adjacent ones — the same principle already applied to how literal runs are split — and the case expects [].

Both corrections point the same way: a conformance case should record what the implementations agree on, or a decision the maintainers have taken, and never a preference of whoever wrote the case. Checking against four implementations instead of two is what surfaced the difference, and it seems worth doing for any suite added here.

Two things the same run turned up, which belong in their own issues rather than this PR:

  • Swift accepts non-ASCII identifiers (${señor}, ${café/precio}, ${日本}) where the other three reject them. Filed separately — the three that reject are, I think, the ones in the wrong.
  • The Python parser raises bare ValueError rather than the SDK's own A2uiParseError, so a Python harness cannot map this suite's ParseError category onto anything. Worth fixing before a Python harness is written for these suites.

…ser-conformance

# Conflicts:
#	conformance/README.md
#	conformance/conformance_schema.json
@github-actions github-actions Bot added status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs and removed status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs labels Sep 4, 2026
@Varun-S10

Copy link
Copy Markdown
Collaborator

Hi @gspencergoog, I have pulled and verified the changes locally, and all tests pass as expected. Could you please review this PR?

@gspencergoog gspencergoog left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! Thank you for implementing the conformance tests for this.

@gspencergoog

Copy link
Copy Markdown
Collaborator

Once you update the changelog, I can commit this.

///
/// Every client implementation accepts a trailing point (`1.`) today and none
/// accepts a second point (`1.2.3`), so the grammar is written to keep that.
final RegExp _numberLiteral = RegExp(r'^\d+\.?\d*$');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't allow negative number literals. Or exponential notation. Or commas instead of decimals (locale support). (I don't think we actually support commas elsewhere yet either, but we shouldn't rule it out.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, actually, commas can't work anyhow because of the parser (arguments are comma-separated). We'd have to do that in the presentation layer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants