# Porting notes for package expr The package implements the expression language of GitHub Actions as **the C# runner** (actions/runner, `src/Sdk/DTExpressions2`) runs it on github.com. It started as a port of `@actions/expressions` 0.3.61 (MIT, GitHub, actions/languageservices), the JavaScript used by the editors, and was then checked against the runner. Where the two differ the runner wins, because it is what users see at run time; the differences are listed below, with the C# source that decides each of them. The JavaScript is still the reference for the token stream and positions of `Lex`, which `Parse` no longer uses. Three kinds of evidence back the tests: - `testdata/oracle.json.gz`: what the JavaScript answers on 34 765 expressions (tokens, error messages, result kinds, string forms, `toJSON`), generated by `gen/oracle.mjs`; `TestOracle` replays it, and excuses a mismatch only where the runner gives this package's answer. - `testdata/runner.json.gz`: what the runner's own code answers on the same expressions. `gen/runner` compiles `src/Sdk/DTExpressions2` (plus stubs for the few assemblies it needs, see `gen/runner/README.md`) and runs it; `TestRunnerOracle` holds the package to it: 34 765 cases with the same result, string form, `toJSON` text and, for errors, the same message (parse errors included, to the position); the only exception is the message text of Json.NET failures, which must be `fromJSON` failures on both sides. The same driver recorded `testdata/tojson/*.runner`, `ordinal_cases.txt` and `number_cases.txt`, and checked the number formatting on 200 000 random doubles. - `testdata/languageservices/*.json`: the 1 020 cross-language fixtures of actions/languageservices (see `xlang_test.go`): 1 013 pass, 7 are known differences with a reason: negative zero (5 cases, below), `case/27` (the runner reports an even `case()` on the `(`, not the name) and `syntax-errors/memory-errors/0` (the runner's memory accounting). `testdata/languageservices/SOURCE` has the commit. The runner's own expression tests (`ExpressionParserL0`, `ConditionFunctionsL0`) are ported in `runner_l0_test.go`; they are the only tests of the expression engine in the runner's `src/Test/L0` (the other files that mention it test templates and actions). The case-mapping tables of the JavaScript (`casing.go`, `casing_tables.go`, `gen/casing*`) are kept for `Lex` and the oracle tests; the code `Parse` and `Evaluate` use for strings is `ordinal.go`, `number.go` and `json.go`. The WTF-8 helpers in `casing.go` are only reached through `Lex` and tests. Regenerating: see `gen/runner/README.md`. The scripts need a clone of actions/runner at the commit `5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798` (`gen/runner/runner-commit.txt`, asserted by the scripts and by `TestRunnerCommit`), the .NET SDK and a 9.0.x runtime (recorded: 9.0.20); none of it is needed by `go test`. ### The two engines of the runner, and which one runs The runner contains two copies of the expression engine: `src/Sdk/DTExpressions2/Expressions2` (namespace `GitHub.DistributedTask. Expressions2`, "legacy") and `src/Sdk/Expressions` (namespace `GitHub.Actions. Expressions`, "newer", with its own `ExpressionData` types, `JsonParser` and options such as `StrictJsonParsing`). They are separate implementations, almost line for line the same. - **Which one runs.** The legacy one. `Runner.Worker/PipelineTemplateEvaluatorWrapper.cs` (used by `ExecutionContext.ToPipelineTemplateEvaluator`) and `ActionManifestManagerWrapper.cs` call `EvaluateAndCompare(legacy, new, ...)`: both evaluators run, the **legacy result (or its exception) is returned**, and a difference is only reported as job telemetry (`TemplateEvaluatorMismatch`, `RecordMismatch`). So the newer engine is a shadow being validated, and what users see is the legacy behaviour. This is why `gen/runner` builds the legacy engine. - **How they differ.** `gen/runner-new` builds the newer one with the same driver and `gen/runner-new/engines.mjs` runs 46 148 expressions through both (the 34 765 of the oracle, the 1 791 `fromJSON` inputs, the number, comparison, `contains`, `startsWith` and `endsWith` corpora): the results are identical except for **negative zero** (the newer engine's `EvaluationResult. ConvertToString` maps `-0` to `0`, the legacy one prints `-0`; 3 expressions) and the **message of a `fromJSON` failure** (the newer one wraps it in "Error parsing fromJson"; both are `JsonReaderException`). In the sources the newer engine also has options the legacy one lacks: `StrictJsonParsing` (a `System.Text.Json` path in `fromJSON`, off by default, "temporary for telemetry"), `AlwaysTraceExpanded` and `MaxCacheMemory` (tracing), and its own `JsonParser`; none changes a result with the default options. - **Consequence for this package.** It follows the legacy engine, as run today. Negative zero is the one place where the newer engine, the shared cross-language fixtures and this package agree against the legacy one; if the runner switches engines nothing else changes. See "Still open". ## Limits added by this port (not in the JavaScript or the runner) The JavaScript has no bound on what an evaluation may build. These are additions, each reported as an `*EvalError` (or `*Error` for the parser) and none changes the result of an evaluation that stays within them. - **Output budget** (`MaxResultBytes`, 1 MiB by default, `EvalContext. MaxResultBytes` to override, negative to disable). One budget per `Evaluate`, charged by `format` (every piece appended), `join` (items and separators), `toJSON` (the encoder stops as soon as the output would exceed what is left) and by every element of the arrays built by `.*` / `[*]` (16 bytes each). `format('{0}{0}', format('{0}{0}', ...))` chains and `a.*.*.*` fan-outs over shared arrays are cut off instead of exhausting memory. Strings that come out of `fromJSON` are not charged: their size is bounded by the input the caller provided. The cost per filtered element is a guess; revisit it if real workflows hit the limit. - **Cancellation**: `Evaluate(ctx, ...)` checks `ctx` every 64 nodes and in the loops of filters, `join` and `contains`, and passes `ctx` to extension functions. - **Evaluation depth** of 1000 for hand-built trees (parsed trees stay far below it), **JSON depth** `MaxJSONDepth` = 1000 for the encoder (`FromJSON` is limited to 64 levels like Json.NET) (cyclic values built with `Array.Append` yield an error from `EncodeJSON`, `"null"` from `ToJSON`, an `*EvalError` from `toJSON()`). The JS recursion limit is engine-dependent, around 5 000 to 10 000. - **Built-in arity is re-checked at evaluation**, so a hand-built AST gets an `*EvalError`, not a panic. - **Input length** is checked before the input is converted, without allocating. Limits that the runner has and this package follows: expression length 21000 UTF-16 units and tree depth 50 (see below). ## Performance notes (deferred) - GO-008: `Evaluate` rebuilds the case-folded context and function maps on every call, and `foldKey` lower-cases on each lookup. Fine for per-step evaluation; when a caller evaluates thousands of expressions against the same contexts, add a prepared `Environment` (fold once, reuse) and cache folded names in the AST. ## Where the JavaScript differs from the C# runner (and what this package does) Each item names the runner source that decides it. All are resolved; the package follows the runner. - [x] **String escapes: every `''` is unescaped.** Resolved: follows the C#. `LexicalAnalyzer.ReadStringToken` (`Tokens/LexicalAnalyzer.cs`, the "Escaped single quote" branch) skips the second quote of each pair and appends the first, so `'a''b''c'` is `a'b'c`. The JavaScript lexer does `value.replace("''", "'")` (first pair only) and gives `a'b''c`; that is a bug of the editor library. Tests: `TestEvalLiteralsAndOperators`; the oracle excuses the JS mismatch because `testdata/runner.json.gz` agrees with this package. - [x] **`case()` availability.** Resolved: the runner has `case` always (`ExpressionConstants`: `AddFunction("case", 3, Byte.MaxValue)`), with an odd number of arguments enforced at parse time (`ExpressionParser.FlushTopEndParameters`: `EvenParameters`, reported on the `(` token) and at evaluation (`Sdk/Functions/Case.cs`), predicates must be booleans ("case predicate must evaluate to a boolean value"), and only the matching branch is evaluated. `ExpressionParserL0` has a regression test that `case(...)` must not turn on "allow unknown keywords" (ported in `runner_l0_test.go`). Here it is always on, with the maximum of 255 arguments (the JS has no maximum). - [x] **`format()`**: resolved, follows the C#. `Sdk/Functions/Format.cs`: the format string is scanned first (syntax and "more arguments than were supplied" are reported before any argument is evaluated), arguments are then evaluated lazily, once each. The index is a **byte** (`Byte.TryParse`): `{255}` is the last valid index, `{256}` and `{12345678901234567890123}` are "format string is invalid", not "more arguments". After the digits only `}` or `:specifiers}` are allowed (`{0 }`, `{0,5}` are invalid); **specifiers are an error** for every value: `The format specifiers 'x' are not valid for objects of type 'Number'` (kind names `Null Boolean Number String Array Object`). Messages are the same as the JS ones otherwise (`ExpressionResources.resx`: `InvalidFormatString`, `InvalidFormatArgIndex`, `InvalidFormatSpecifiers`). The runner has the same 255-argument cap as the JS (`Byte.MaxValue`, `ExpressionConstants`). - [x] **`contains()`, `startsWith`/`endsWith`, `==`, `<`, `>` and key lookup use `OrdinalIgnoreCase`.** Resolved: follows the C#. Every string comparison in the runner is `StringComparison.OrdinalIgnoreCase` (`EvaluationResult.cs` `AbstractEqual`/`AbstractGreaterThan`/ `AbstractLessThan`; `Sdk/Functions/Contains.cs`, `StartsWith.cs`, `EndsWith.cs`; `StringComparer.OrdinalIgnoreCase` for context names, function names (`ExpressionParser.cs` `ParseContext`, `ExpressionConstants.WellKnownFunctions`) and dictionary keys (`DictionaryContextData.IndexLookup`)). That folds one code point at a time with a simple mapping: `ß` is not `SS`, `ı` and `İ` and `ſ` and the Kelvin sign are not `i`/`I`/`s`/`k`, `ς` and `σ` are equal, `Dž` is `dž`. `contains()` no longer lower-cases (JS) and `startsWith`/`endsWith` no longer upper-case with full mappings (JS). Ordering (`<`, `>`) is by code point after folding, so a supplementary character sorts after U+E000..U+FFFF (JS orders UTF-16 units). `ordinal.go` implements it; `fold_tables.go` is generated from the runner's behaviour (`go generate`, `gen/runner --fold`), and `TestOrdinalFoldMatchesRunner` replays 1 564 pairs recorded from .NET. The table is the one of the .NET runtime that produced it (9.0); the runner ships its own runtime, so a different Unicode version could differ on newly assigned letters. - [x] **`==` on strings, `<`, `>`**: see the previous item (same folding). - [x] **Number to string (`CoerceString`)**: resolved, follows the C#. The runner converts every number with `Double.ToString("G15", InvariantCulture)` (`ExpressionConstants.NumberFormat`, `EvaluationResult.ConvertToString`, `src/Sdk/DTExpressions2/Expressions2/EvaluationResult.cs`), not `(+x.toFixed(15)).toString()`: 15 significant digits, positional for decimal exponents -5 < X < 15, otherwise `1E+21`, `1E-07`, `1.23456789012346E+17` (uppercase `E`, at least two exponent digits). So `1e15` is `1E+15`, `123456789012345678` is `1.23456789012346E+17`, `0.000001` is `1E-06`, `5e-324` is `4.94065645841247E-324`, `1e309` is `Infinity`. Negative zero is `-0` (see the Negative zero item below). Checked against the runner's code on 200 000 random doubles. The number *literal* in an expression is parsed independently of this (see below) and the JSON writer of `toJSON` uses the same conversion (next item). - [x] **String to number**: resolved, follows the C#. `ExpressionUtility. ParseNumber` (`src/Sdk/DTExpressions2/Expressions2/Sdk/ExpressionUtility.cs`) trims with `String.Trim()` (so U+0085 is white space and U+FEFF is not), accepts what `Double.TryParse(AllowLeadingSign | AllowDecimalPoint | AllowExponent)` accepts (ASCII digits only, trailing NULs ignored, `Infinity` in any letter case with an optional sign), then `0x` hex and `0o` octal (lower-case prefix only, no sign) read as a **32-bit signed integer** (`0xffffffff` is `-1`, anything over 32 bits is NaN). There is no `0b` form and no upper-case `0X`/`0O`. The lexer uses the same function for number literals, so `0b101`, `0X10` and `0xFFFFFFFFF` are "Unexpected symbol" errors and `0xffffffff` is `-1`. The same function is implemented as `parseNumber` in `number.go`; `TestParseNumberMatchesRunner` replays 443 strings recorded from the runner's code (`gen/runner/number_cases.mjs`). - [x] **Number literals after `-` / `+`**: `-1` is one literal token; `a -1` is therefore a parse error. Same in the C# lexer (`LexicalAnalyzer. TryGetNextToken`: `c == '-' || c == '+' || digit` starts `ReadNumberToken`), checked by the differential run. - [x] **Object key order from `fromJSON`**: resolved, follows the C#. Keys keep the document order (`JTokenExtensions.ToPipelineContextData` iterates `JObject.Properties()` into `DictionaryContextData`, a list); the JS `Object.keys` order (array-index keys first) is gone, from `FromJSON` and from `toJSON`. `toJSON(fromJSON('{"b":1,"1":2}'))` is `b` then `1`. - [x] **Duplicate JSON keys**: resolved, follows the C#. `subContext[property.Name] = ...` on a `DictionaryContextData` whose index is `OrdinalIgnoreCase` (`DictionaryContextData.this[]` setter): the last value wins, at the position and with the spelling of the first. `{"a":1,"A":2}` is `{"a": 2}` (the JS keeps `1`). Json.NET itself also merges exact duplicates (last wins). - [x] **`fromJSON` errors and grammar**: resolved, follows the C#. The reader is Json.NET's (`Sdk/Functions/FromJson.cs`: `JToken.ReadFrom` on a `JsonTextReader` with `DateParseHandling.None`, `FloatParseHandling.Double`), which is lenient: single-quoted strings, unquoted names, trailing commas, `[,1]` (an empty element is `""`), `NaN`/`Infinity`/`-Infinity`/`undefined`, `.5`, `5.`, `1.e2`, `0x1F`, raw control characters in strings, anything after the first value, and `MaxDepth` 64. A comma where the document should start (`,1`) is an undefined value, `""`. **Leading zeros are octal**: a non-negative integer with a leading zero is `Convert.ToInt64(s, 8)` (`010` is 8, `050` is 40, `0777` is 511, wraps like a signed 64-bit value), `08` and `09` are errors, as are `01.5`, `012e1`, `00.5` (a leading zero before `.`, `e` or `E` is only valid for a single `0`); a negative number is decimal (`-010` is -10, `-01.5` is -1.5). An unpaired surrogate escape becomes U+FFFD. Blank input is a `JsonReaderException` ("Error reading JToken from JsonReader."). **Error texts**: a failure is a `*JSONError` whose text is the Json.NET message GitHub prints, ` Path '', line , position .` (`not json` is `Unexpected character encountered while parsing value: n. Path '', line 0, position 0.`). Reasons, paths and positions are ported from `JsonTextReader` and checked against the runner on the 1 459 rejected inputs of `testdata/fromjson_errors.json` (`gen/runner/fromjson_errors.mjs`); the 59 that involve comments, constructors (`new Date(1)`) or a closing bracket of the wrong kind are not reproduced (the message of those may differ in reason or position). Not ported: `/* */` and `//` comments (the runner reads `/*x*/1` as the string `x`) and `new Date(1)` constructors; both are errors here. A `/` after `null`, `true`, `false`, `NaN`, `Infinity`, `-Infinity` or `undefined`, and a `/` between values inside an array or object, is an error here as in the runner (where it starts a comment or is an error); after a number or a string at the top level the rest is ignored in both. Checked on 1 791 inputs recorded from the runner (`testdata/fromjson_cases.json`, hand picked and seeded mutations; the 24 that contain a `/` or `new ` are skipped by the test), also used as fuzz seeds. - [x] **`toJSON`**: resolved, follows the C#. `Sdk/Functions/ToJson.cs` builds the text itself: two-space indentation, `[]`/`{}` for empties, numbers through `ConvertToString` (so `NaN`, `Infinity` and `-Infinity` appear bare, `1E+21`, 15 significant digits, `-0` is `-0`) and strings through `JsonUtility.ToString` (Json.NET defaults: `\b \f \n \r \t`, `\u00XX` for other controls, `\u0085 \u2028 \u2029`, no escaping of `< > & '`, and non-ASCII as is). Checked against the runner's code (`testdata/tojson/*.runner`). The JS `null` for non-finite numbers, the shortest round-trip form and the raw U+2028 are not followed. Caveat: `JsonUtility` is stubbed in `gen/runner/Stubs.cs` with `JsonConvert.SerializeObject`; the real one passes the runner's `VssJsonMediaTypeFormatter` settings, which do not change string escaping. - [x] **Lazy evaluation of built-in function arguments and of index expressions**: found by the differential run, not in the original list. In the runner a function evaluates only the arguments it uses (`Parameters[i].Evaluate(context)` inside `EvaluateCore`): `case` stops at the first true predicate, `contains`/`startsWith`/`endsWith` do not evaluate the second argument when the first is not primitive (or, for `contains`, not a non-empty array), `join` evaluates the separator only with two or more items, `format` evaluates only referenced arguments. And `a[b]` (`Index.cs`) evaluates `b` only if `a` is an array or a dictionary, and does not wrap its errors (the JS evaluates `b` first and wraps them in "could not evaluate index for index access"). So `join(success(), format('{2}'))` is `true` in the runner and an error in the JS. Built-ins now receive the call and evaluate what they need; extension functions still get evaluated arguments. - [x] **Large integers in `fromJSON`**: found by the differential run. Json.NET reads an integer that does not fit in 64 bits as a `BigInteger`, and `(Double)BigInteger` truncates toward zero to 53 bits instead of rounding to nearest, so `fromJSON('18446744073709551615')` is 2^64-2048, not 2^64, and does not equal the literal `18446744073709551615` of an expression (which `Double.TryParse` rounds to nearest). Reproduced in `jsonParser.number` (`TestFromJSONBigIntegers`); checked on 57 values. - [x] **Index access**: resolved, same in the C# (`Sdk/Operators/Index.cs`, `IndexHelper`): a number is floored, NaN, negatives and numbers above Int32.MaxValue give no index; a primitive also has a string form for dictionary keys, so booleans and null are `"true"`/`""` there and 1/0 on arrays; `' 1 '` and `'1.9'` index element 1. Differences found: the index is only evaluated when the object is an array or a dictionary, and its errors are not wrapped (see the lazy evaluation item above). `TestRunnerSemantics`. - [x] **Filtered arrays** (`a.*`): resolved, same in the C# (`Index.HandleFilteredArray`): `*` on a dictionary yields its values, `.*.b` skips elements without `b`, `.*.*` flattens one level, and every `*` builds a new array, so `a.* == a.*` is false. `TestRunnerSemantics`. - [x] **Comparison**: resolved, same in the C# (`EvaluationResult.cs` `CoerceTypes`/`AbstractEqual`/`AbstractGreaterThan`): null and booleans become numbers when compared with another kind, a string compared with a number is parsed with `ParseNumber`, arrays and dictionaries compare by reference and are never ordered, NaN is unequal to itself and unordered. Strings compare with `OrdinalIgnoreCase` (see above). `TestRunnerSemantics`. - [x] **`&&`/`||` return an operand**: resolved, same in the C# (`And.cs`, `Or.cs`: they return `result.Value` of the deciding operand). - [x] **Limits**: resolved, follows the C#. Length 21000 UTF-16 units (`ExpressionConstants.MaxLength`): "Exceeded max expression length 21000", without a token (the JS throws a bare `ErrorExceededMaxLength`). Depth 50 is checked on the finished tree (`ExpressionParser.CheckMaxDepth`): the root is depth 1, an operand of an operator, an argument of a call and the two sides of an index are one deeper, **parentheses add nothing**, and a chain of `&&` or `||` is one node, nested ones included, so `((((...1...))))` with 500 parentheses and `1 || (1 || (1 ...))` parse; `"!" * 50 + "true"` and 50 comparisons chained with `==` do not. The error has no token: "Exceeded max expression depth 50". The JS limit counted parser recursion (parentheses included). Memory: the runner has `EvaluationOptions.MaxMemory` (1 MiB by default) and fails with "The maximum allowed memory size was exceeded while evaluating the following expression: ...", counting strings at 26 bytes + 2 per UTF-16 unit and 24 per object, per call depth (`EvaluationMemory`). This package keeps its own output budget (see above), which is not byte-for-byte the same, so the fixture `syntax-errors.json` "memory-errors" is a known difference. - [x] **Trailing comma in calls**: resolved, follows the C#. `contains('a', 'b',)` is "Unexpected symbol: ')'" (the tokenizer rejects a `)` after a `,`). The JS accepted it. `f(,)` and `contains(,'a')` fail as before. - [x] **Whitespace**: resolved, follows the C#. `Char.IsWhiteSpace` separates tokens: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000..U+200A, U+2028, U+2029, U+202F, U+205F, U+3000. So `1 \\u00a0 2` is "Unexpected symbol: '2'" (not the NBSP), and `\\v1` is 1. The JS skipped only space, tab, CR and LF. U+FEFF is not white space. - [x] **Empty expression**: same in the C# (`CreateTree` returns null when there is no token). `Parse` returns a nil Node and no error; `Evaluate(nil)` errors; callers (the template layer) decide. - [x] **Unknown contexts at evaluation time** are null: in the runner a declared named value is evaluated by the caller's node; here an absent key of `EvalContext.Contexts` is null. - [x] **Error positions**: resolved, follows the C#. A `ParseException` names the token and its **absolute 1-based index in the expression** (`Located at position N within expression: `); `Error.Pos` is the same place as a zero-based line and column. Arity and even-argument errors name the `(` of the call (the token `FlushTopEndParameters` holds), not the function: `contains()` is "Too few parameters supplied: '('. Located at position 9". The end-of-input error is "Unexpected end of expression: ''" (the JS said "Unexpected symbol: 'EOF'"), and the JS's negative EOF column is gone with the JS lexer. Unexpected-symbol tokens are the boundary-delimited run (`ReadKeywordToken`): `github..event` reports `.` where the JS reported `.event`. Lexing and parsing are interleaved, so the first problem in reading order wins (the JS lexed everything first). `TestParseErrorPositions`, and `TestRunnerOracle` on the 1 715 parse errors of the corpus (identical message, position included). One fixture still differs, see `xlangKnownDifferences`: `case/27` expects the name for an even `case()`, the runner passes the `(`. - [x] **Function names and context names are case-insensitive**: same in the C# (`StringComparer.OrdinalIgnoreCase` dictionaries; see the folding item). Built-ins win over extension functions of the same name at validation **and at evaluation** (`ExpressionParser.TryGetFunctionInfo` looks in `ExpressionConstants.WellKnownFunctions` first and builds the node from it); the JS let an extension override a built-in at evaluation. An extension supplied under a built-in's name is now never called. ## JavaScript behaviours deliberately NOT ported These are accidents of JavaScript objects used as maps; porting them would be a bug. - `Dictionary.add` tests `key.toLowerCase() in this.indexMap` on a plain object, so keys that collide with `Object.prototype` properties (`constructor`, `__proto__`, `valueOf`, ...) are silently dropped or hidden. Here every key is stored. - `wellKnownFunctions[name]` is a plain-object lookup, so `constructor(1)`, `toString(1)` and `hasOwnProperty(1)` pass validation or crash with a `TypeError` instead of "Unrecognized function". Here they are "Unrecognized function". - A `TypeError` in the JS (`Cannot read properties of undefined`) when evaluating e.g. `github.true` with an empty context: the reference does not handle a missing root context. Here it is null. - Recursion limits: JS recursion depth for `fromJSON` input is engine dependent. `FromJSON` follows Json.NET's `MaxDepth` of 64. - Lone surrogates: JS strings are UTF-16 and can hold them. .NET strings can too, but no value of an expression has one: Json.NET reads an unpaired `\ud800` escape as U+FFFD (checked: `fromJSON('"\ud800"') == '\ufffd'` is true), and Go strings cannot hold them either. The WTF-8 helpers of the first version (`wtf8Surrogate`, `normalizeWTF8` in `casing.go`) are only reached through `Lex`-era code and the tests. ## Still open - [ ] **Memory accounting.** The runner caps an evaluation at `MaxMemory` (1 MiB) counted by `EvaluationMemory`/`MemoryCounter` (26 bytes + 2 per UTF-16 unit per string, 24 per object, trimmed per call depth); this package caps `MaxResultBytes` (1 MiB of output, different units) and accepts `format(...)` chains the runner rejects. Fixture `syntax-errors/memory-errors/0` is the known difference. Reproducing `EvaluationMemory` exactly would need the node depth of every value; worth doing only if the error text matters ("The maximum allowed memory size was exceeded while evaluating the following expression: ...", which needs `ConvertToExpression` of the root, also not ported). - [x] **Negative zero**: `-0` is `-0`, as the legacy engine that runs today (`DTExpressions2/EvaluationResult.ConvertToString` formats with `G15` and has no special case; .NET Core writes `-0`): `format('{0}', -0)`, `-0` as a result and `toJSON(-0)` (`toJSON` uses `ConvertToString`). A JSON integer token `-0` or `-00` is read as the long 0, but `-0.0` keeps its sign. The newer engine (`Expressions/EvaluationResult.cs`, "keep our behavior consistent") maps it to `0`, and so do these shared fixtures: `coerce_string/number#10-11`, `format/format#19-20` and `tojson/basics#4` (5 cases) are listed in `xlangKnownDifferences` with this evidence. `engines.mjs` shows it is the only result on which the engines differ. **Track the runner's switch to `src/Sdk/Expressions` (`TemplateEvaluatorMismatch` telemetry); re-run `gen/runner-new/engines.mjs` on each runner release.** If it switches, change `numberToCoercedString` back to `"0"` (one line) and the tests that mention `-0`. - [ ] **Comments and constructors in `fromJSON`.** Json.NET accepts `/* */` and `//` comments and `new Date(1)` constructors; the first is read in a surprising way (`/*x*/1` is the string `x`). They fail here. - [ ] **`fromJSON` error texts.** Json.NET messages ("Unexpected character encountered while parsing value: a. Path '', line 0, position 0.") are not reproduced; the outer message "Error parsing JSON when evaluating fromJson" is the JS one, the runner's exception message is the Json.NET one. Only the failure is checked. - [ ] **`StrictJsonParsing`.** The newer engine has an option that switches `fromJSON` to `System.Text.Json` (strict, 64 levels, different messages); default off, "temporary for telemetry". Not implemented. - [ ] **Unicode version of the folding table** is that of the .NET 9 runtime that generated `fold_tables.go`; a runner on another runtime may fold newly assigned letters differently. - [ ] **Tracing and secret masking** (`ITraceWriter`, `ISecretMasker`, `EvaluationResult` tracing) are runner features outside this package. ## Left to the callers - Run the runner's expression test vectors: done for `ExpressionParserL0` and `ConditionFunctionsL0` (`runner_l0_test.go`). The runner has no other expression tests in `src/Test/L0` (the larger suites are in the closed service); the cross-language fixtures cover the rest. - The functions the runner implements natively with special semantics: `success()`, `failure()`, `always()`, `cancelled()` (no arguments; read job status, or `action_status` in a composite action's main step) and `hashFiles()` (variadic, string result) are supplied by the caller through `FuncInfo` + `Func`; `runner_l0_test.go` shows the declaration the runner uses (`FunctionInfo(name, 0, 0)`). - Context names per location and which functions each location may call belong to the template layer. - The `format()` argument limit is 255 in both (`Byte.MaxValue`). - Secret masking and the `${{ }}` splitting of a larger string are not part of this package: `Parse` takes the text between the braces only.