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 depthMaxJSONDepth = 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.
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.
case() availability. Resolved: the runner has case always
(ExpressionConstants: AddFunction<Case>("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).
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).
contains(), startsWith/endsWith, ==, <, > and key lookup use
OrdinalIgnoreCase. Resolved: follows the C#. Every string comparison
in the runner is StringComparison.OrdinalIgnoreCase
(EvaluationResult.csAbstractEqual/AbstractGreaterThan/
AbstractLessThan; Sdk/Functions/Contains.cs, StartsWith.cs,
EndsWith.cs; StringComparer.OrdinalIgnoreCase for context names,
function names (ExpressionParser.csParseContext,
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.
== on strings, <, >: see the previous item (same folding).
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).
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).
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.
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.
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).
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, <reason> Path '<path>', line <n>, position <n>. (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.
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.
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.
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.
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.
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.
Comparison: resolved, same in the C# (EvaluationResult.csCoerceTypes/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.
&&/|| return an operand: resolved, same in the C# (And.cs,
Or.cs: they return result.Value of the deciding operand).
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.
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.
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.
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.
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.
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: <whole 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 (.
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).
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.
source
1# Porting notes for package expr23The package implements the expression language of GitHub Actions as **the C#4runner** (actions/runner, `src/Sdk/DTExpressions2`) runs it on github.com. It5started as a port of `@actions/expressions` 0.3.61 (MIT, GitHub,6actions/languageservices), the JavaScript used by the editors, and was then7checked against the runner. Where the two differ the runner wins, because it8is what users see at run time; the differences are listed below, with the C#9source that decides each of them. The JavaScript is still the reference for10the token stream and positions of `Lex`, which `Parse` no longer uses.1112Three kinds of evidence back the tests:1314-`testdata/oracle.json.gz`: what the JavaScript answers on 34 765 expressions15 (tokens, error messages, result kinds, string forms, `toJSON`), generated by16`gen/oracle.mjs`; `TestOracle` replays it, and excuses a mismatch only where17 the runner gives this package's answer.18-`testdata/runner.json.gz`: what the runner's own code answers on the same19 expressions. `gen/runner` compiles `src/Sdk/DTExpressions2` (plus stubs for20 the few assemblies it needs, see `gen/runner/README.md`) and runs it;21`TestRunnerOracle` holds the package to it: 34 765 cases with the same22 result, string form, `toJSON` text and, for errors, the same message (parse23 errors included, to the position); the only exception is the message text of24 Json.NET failures, which must be `fromJSON` failures on both sides.25 The same driver recorded `testdata/tojson/*.runner`, `ordinal_cases.txt`26 and `number_cases.txt`, and checked the number formatting on 200 000 random27 doubles.28-`testdata/languageservices/*.json`: the 1 020 cross-language fixtures of29 actions/languageservices (see `xlang_test.go`): 1 013 pass, 7 are known30 differences with a reason: negative zero (5 cases, below), `case/27` (the runner reports an even `case()` on31 the `(`, not the name) and `syntax-errors/memory-errors/0` (the runner's32 memory accounting). `testdata/languageservices/SOURCE` has the commit.3334The runner's own expression tests (`ExpressionParserL0`, `ConditionFunctionsL0`)35are ported in `runner_l0_test.go`; they are the only tests of the expression36engine in the runner's `src/Test/L0` (the other files that mention it test37templates and actions). The case-mapping tables of the JavaScript (`casing.go`,38`casing_tables.go`, `gen/casing*`) are kept for `Lex` and the oracle tests; the39code `Parse` and `Evaluate` use for strings is `ordinal.go`, `number.go` and40`json.go`. The WTF-8 helpers in `casing.go` are only reached through `Lex` and41tests.4243Regenerating: see `gen/runner/README.md`. The scripts need a clone of44actions/runner at the commit `5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798`45(`gen/runner/runner-commit.txt`, asserted by the scripts and by46`TestRunnerCommit`), the .NET SDK and a 9.0.x runtime (recorded: 9.0.20); none of47it is needed by `go test`.4849### The two engines of the runner, and which one runs5051The runner contains two copies of the expression engine:52`src/Sdk/DTExpressions2/Expressions2` (namespace `GitHub.DistributedTask.53Expressions2`, "legacy") and `src/Sdk/Expressions` (namespace `GitHub.Actions.54Expressions`, "newer", with its own `ExpressionData` types, `JsonParser` and55options such as `StrictJsonParsing`). They are separate implementations, almost56line for line the same.5758- **Which one runs.** The legacy one. `Runner.Worker/PipelineTemplateEvaluatorWrapper.cs`59 (used by `ExecutionContext.ToPipelineTemplateEvaluator`) and60`ActionManifestManagerWrapper.cs` call `EvaluateAndCompare(legacy, new, ...)`:61 both evaluators run, the **legacy result (or its exception) is returned**, and a62 difference is only reported as job telemetry (`TemplateEvaluatorMismatch`,63`RecordMismatch`). So the newer engine is a shadow being validated, and what64 users see is the legacy behaviour. This is why `gen/runner` builds the65 legacy engine.66- **How they differ.** `gen/runner-new` builds the newer one with the same driver67 and `gen/runner-new/engines.mjs` runs 46 148 expressions through both (the68 34 765 of the oracle, the 1 791 `fromJSON` inputs, the number, comparison,69`contains`, `startsWith` and `endsWith` corpora): the results are identical70 except for **negative zero** (the newer engine's `EvaluationResult.71 ConvertToString` maps `-0` to `0`, the legacy one prints `-0`; 3 expressions)72 and the **message of a `fromJSON` failure** (the newer one wraps it in "Error73 parsing fromJson"; both are `JsonReaderException`). In the sources the74 newer engine also has options the legacy one lacks: `StrictJsonParsing` (a75`System.Text.Json` path in `fromJSON`, off by default, "temporary for76 telemetry"), `AlwaysTraceExpanded` and `MaxCacheMemory` (tracing), and its77 own `JsonParser`; none changes a result with the default options.78- **Consequence for this package.** It follows the legacy engine, as run today.79 Negative zero is the one place where the newer engine, the shared80 cross-language fixtures and this package agree against the legacy one; if81 the runner switches engines nothing else changes. See "Still open".8283## Limits added by this port (not in the JavaScript or the runner)8485The JavaScript has no bound on what an evaluation may build. These are86additions, each reported as an `*EvalError` (or `*Error` for the parser) and87none changes the result of an evaluation that stays within them.8889- **Output budget** (`MaxResultBytes`, 1 MiB by default, `EvalContext.90 MaxResultBytes` to override, negative to disable). One budget per91 `Evaluate`, charged by `format` (every piece appended), `join` (items and92 separators), `toJSON` (the encoder stops as soon as the output would exceed93 what is left) and by every element of the arrays built by `.*` / `[*]`94 (16 bytes each). `format('{0}{0}', format('{0}{0}', ...))` chains and95 `a.*.*.*` fan-outs over shared arrays are cut off instead of exhausting96 memory. Strings that come out of `fromJSON` are not charged: their size is97 bounded by the input the caller provided. The cost per filtered element is a98 guess; revisit it if real workflows hit the limit.99- **Cancellation**: `Evaluate(ctx, ...)` checks `ctx` every 64 nodes and in the100 loops of filters, `join` and `contains`, and passes `ctx` to extension101 functions.102- **Evaluation depth** of 1000 for hand-built trees (parsed trees stay far103 below it), **JSON depth** `MaxJSONDepth` = 1000 for the104 encoder (`FromJSON` is limited to 64 levels like Json.NET) (cyclic values built with `Array.Append` yield an error from105 `EncodeJSON`, `"null"` from `ToJSON`, an `*EvalError` from `toJSON()`).106 The JS recursion limit is engine-dependent, around 5 000 to 10 000.107- **Built-in arity is re-checked at evaluation**, so a hand-built AST gets an108 `*EvalError`, not a panic.109- **Input length** is checked before the input is converted, without110 allocating.111112Limits that the runner has and this package follows: expression length 21000113UTF-16 units and tree depth 50 (see below).114115## Performance notes (deferred)116117- GO-008: `Evaluate` rebuilds the case-folded context and function maps on118 every call, and `foldKey` lower-cases on each lookup. Fine for per-step119 evaluation; when a caller evaluates thousands of expressions against the120 same contexts, add a prepared `Environment` (fold once, reuse) and cache121 folded names in the AST.122123## Where the JavaScript differs from the C# runner (and what this package does)124125Each item names the runner source that decides it. All are resolved; the126package follows the runner.127128- [x] **String escapes: every `''` is unescaped.** Resolved: follows the C#.129 `LexicalAnalyzer.ReadStringToken` (`Tokens/LexicalAnalyzer.cs`, the130 "Escaped single quote" branch) skips the second quote of each pair and131 appends the first, so `'a''b''c'` is `a'b'c`. The JavaScript lexer does132 `value.replace("''", "'")` (first pair only) and gives `a'b''c`; that is133 a bug of the editor library. Tests: `TestEvalLiteralsAndOperators`; the134 oracle excuses the JS mismatch because `testdata/runner.json.gz` agrees135 with this package.136- [x] **`case()` availability.** Resolved: the runner has `case` always137 (`ExpressionConstants`: `AddFunction<Case>("case", 3, Byte.MaxValue)`),138 with an odd number of arguments enforced at parse time139 (`ExpressionParser.FlushTopEndParameters`: `EvenParameters`, reported on140 the `(` token) and at evaluation (`Sdk/Functions/Case.cs`), predicates141 must be booleans ("case predicate must evaluate to a boolean value"), and142 only the matching branch is evaluated. `ExpressionParserL0` has a143 regression test that `case(...)` must not turn on "allow unknown144 keywords" (ported in `runner_l0_test.go`). Here it is always on, with145 the maximum of 255 arguments (the JS has no maximum).146- [x] **`format()`**: resolved, follows the C#. `Sdk/Functions/Format.cs`: the147 format string is scanned first (syntax and "more arguments than were148 supplied" are reported before any argument is evaluated), arguments are149 then evaluated lazily, once each. The index is a **byte**150 (`Byte.TryParse`): `{255}` is the last valid index, `{256}` and151 `{12345678901234567890123}` are "format string is invalid", not "more152 arguments". After the digits only `}` or `:specifiers}` are allowed153 (`{0 }`, `{0,5}` are invalid); **specifiers are an error** for every154 value: `The format specifiers 'x' are not valid for objects of type155 'Number'` (kind names `Null Boolean Number String Array Object`).156 Messages are the same as the JS ones otherwise (`ExpressionResources.resx`:157 `InvalidFormatString`, `InvalidFormatArgIndex`, `InvalidFormatSpecifiers`).158 The runner has the same 255-argument cap as the JS (`Byte.MaxValue`,159 `ExpressionConstants`).160- [x] **`contains()`, `startsWith`/`endsWith`, `==`, `<`, `>` and key lookup use161 `OrdinalIgnoreCase`.** Resolved: follows the C#. Every string comparison162 in the runner is `StringComparison.OrdinalIgnoreCase`163 (`EvaluationResult.cs` `AbstractEqual`/`AbstractGreaterThan`/164 `AbstractLessThan`; `Sdk/Functions/Contains.cs`, `StartsWith.cs`,165 `EndsWith.cs`; `StringComparer.OrdinalIgnoreCase` for context names,166 function names (`ExpressionParser.cs` `ParseContext`,167 `ExpressionConstants.WellKnownFunctions`) and dictionary keys168 (`DictionaryContextData.IndexLookup`)). That folds one code point at a169 time with a simple mapping: `ß` is not `SS`, `ı` and `İ` and `ſ` and the170 Kelvin sign are not `i`/`I`/`s`/`k`, `ς` and `σ` are equal, `Dž` is `dž`.171 `contains()` no longer lower-cases (JS) and `startsWith`/`endsWith` no172 longer upper-case with full mappings (JS). Ordering (`<`, `>`) is by173 code point after folding, so a supplementary character sorts after174 U+E000..U+FFFF (JS orders UTF-16 units). `ordinal.go` implements it;175 `fold_tables.go` is generated from the runner's behaviour176 (`go generate`, `gen/runner --fold`), and177 `TestOrdinalFoldMatchesRunner` replays 1 564 pairs recorded from .NET.178 The table is the one of the .NET runtime that produced it (9.0); the179 runner ships its own runtime, so a different Unicode version could differ180 on newly assigned letters.181- [x] **`==` on strings, `<`, `>`**: see the previous item (same folding).182- [x] **Number to string (`CoerceString`)**: resolved, follows the C#. The183 runner converts every number with `Double.ToString("G15",184 InvariantCulture)` (`ExpressionConstants.NumberFormat`,185 `EvaluationResult.ConvertToString`, `src/Sdk/DTExpressions2/Expressions2/EvaluationResult.cs`),186 not `(+x.toFixed(15)).toString()`: 15 significant digits, positional187 for decimal exponents -5 < X < 15, otherwise `1E+21`, `1E-07`,188 `1.23456789012346E+17` (uppercase `E`, at least two exponent digits).189 So `1e15` is `1E+15`, `123456789012345678` is `1.23456789012346E+17`,190 `0.000001` is `1E-06`, `5e-324` is `4.94065645841247E-324`, `1e309` is191 `Infinity`. Negative zero is `-0` (see the Negative zero item below). Checked against the192 runner's code on 200 000 random doubles. The number *literal* in an193 expression is parsed independently of this (see below) and the JSON194 writer of `toJSON` uses the same conversion (next item).195- [x] **String to number**: resolved, follows the C#. `ExpressionUtility.196 ParseNumber` (`src/Sdk/DTExpressions2/Expressions2/Sdk/ExpressionUtility.cs`)197 trims with `String.Trim()` (so U+0085 is white space and U+FEFF is not),198 accepts what `Double.TryParse(AllowLeadingSign | AllowDecimalPoint |199 AllowExponent)` accepts (ASCII digits only, trailing NULs ignored,200 `Infinity` in any letter case with an optional sign), then `0x` hex and201 `0o` octal (lower-case prefix only, no sign) read as a **32-bit signed202 integer** (`0xffffffff` is `-1`, anything over 32 bits is NaN). There is203 no `0b` form and no upper-case `0X`/`0O`. The lexer uses the same204 function for number literals, so `0b101`, `0X10` and `0xFFFFFFFFF` are205 "Unexpected symbol" errors and `0xffffffff` is `-1`. The same function is206 implemented as `parseNumber` in `number.go`;207 `TestParseNumberMatchesRunner` replays 443 strings recorded from the208 runner's code (`gen/runner/number_cases.mjs`).209- [x] **Number literals after `-` / `+`**: `-1` is one literal token; `a -1`210 is therefore a parse error. Same in the C# lexer (`LexicalAnalyzer.211 TryGetNextToken`: `c == '-' || c == '+' || digit` starts `ReadNumberToken`),212 checked by the differential run.213- [x] **Object key order from `fromJSON`**: resolved, follows the C#. Keys keep214 the document order (`JTokenExtensions.ToPipelineContextData` iterates215 `JObject.Properties()` into `DictionaryContextData`, a list); the JS216 `Object.keys` order (array-index keys first) is gone, from `FromJSON` and217 from `toJSON`. `toJSON(fromJSON('{"b":1,"1":2}'))` is `b` then `1`.218- [x] **Duplicate JSON keys**: resolved, follows the C#. `subContext[property.Name] =219 ...` on a `DictionaryContextData` whose index is `OrdinalIgnoreCase`220 (`DictionaryContextData.this[]` setter): the last value wins, at the221 position and with the spelling of the first. `{"a":1,"A":2}` is222 `{"a": 2}` (the JS keeps `1`). Json.NET itself also merges exact223 duplicates (last wins).224- [x] **`fromJSON` errors and grammar**: resolved, follows the C#. The reader is225 Json.NET's (`Sdk/Functions/FromJson.cs`: `JToken.ReadFrom` on a226 `JsonTextReader` with `DateParseHandling.None`,227 `FloatParseHandling.Double`), which is lenient: single-quoted strings,228 unquoted names, trailing commas, `[,1]` (an empty element is `""`),229 `NaN`/`Infinity`/`-Infinity`/`undefined`, `.5`, `5.`, `1.e2`, `0x1F`, raw230 control characters in strings, anything after the first value, and231 `MaxDepth` 64. A comma where the document should start (`,1`) is an232 undefined value, `""`. **Leading zeros are octal**: a non-negative233 integer with a leading zero is `Convert.ToInt64(s, 8)` (`010` is 8, `050`234 is 40, `0777` is 511, wraps like a signed 64-bit value), `08` and `09`235 are errors, as are `01.5`, `012e1`, `00.5` (a leading zero before `.`,236 `e` or `E` is only valid for a single `0`); a negative number is decimal237 (`-010` is -10, `-01.5` is -1.5). An unpaired surrogate escape becomes238 U+FFFD. Blank input is a `JsonReaderException` ("Error reading JToken from239 JsonReader."). **Error texts**: a failure is a `*JSONError` whose text is240 the Json.NET message GitHub prints, `<reason> Path '<path>', line <n>,241 position <n>.` (`not json` is `Unexpected character encountered while242 parsing value: n. Path '', line 0, position 0.`). Reasons, paths and243 positions are ported from `JsonTextReader` and checked against the runner244 on the 1 459 rejected inputs of `testdata/fromjson_errors.json`245 (`gen/runner/fromjson_errors.mjs`); the 59 that involve comments,246 constructors (`new Date(1)`) or a closing bracket of the wrong kind are247 not reproduced (the message of those may differ in reason or position). Not ported: `/* */` and `//` comments (the runner248 reads `/*x*/1` as the string `x`) and `new Date(1)` constructors; both are249 errors here. A `/` after `null`, `true`, `false`, `NaN`, `Infinity`,250 `-Infinity` or `undefined`, and a `/` between values inside an array or251 object, is an error here as in the runner (where it starts a comment or is252 an error); after a number or a string at the top level the rest is ignored253 in both. Checked on 1 791 inputs recorded from the runner254 (`testdata/fromjson_cases.json`, hand picked and seeded mutations; the255 24 that contain a `/` or `new ` are skipped by the test), also used as256 fuzz seeds.257- [x] **`toJSON`**: resolved, follows the C#. `Sdk/Functions/ToJson.cs` builds258 the text itself: two-space indentation, `[]`/`{}` for empties, numbers259 through `ConvertToString` (so `NaN`, `Infinity` and `-Infinity` appear260 bare, `1E+21`, 15 significant digits, `-0` is `-0`) and strings through261 `JsonUtility.ToString` (Json.NET defaults: `\b \f \n \r \t`, `\u00XX`262 for other controls, `\u0085 \u2028 \u2029`, no escaping of `< > & '`, and263 non-ASCII as is). Checked against the runner's code264 (`testdata/tojson/*.runner`). The JS `null` for non-finite numbers, the265 shortest round-trip form and the raw U+2028 are not followed.266 Caveat: `JsonUtility` is stubbed in `gen/runner/Stubs.cs` with267 `JsonConvert.SerializeObject`; the real one passes the runner's268 `VssJsonMediaTypeFormatter` settings, which do not change string269 escaping.270- [x] **Lazy evaluation of built-in function arguments and of index271 expressions**: found by the differential run, not in the original list.272 In the runner a function evaluates only the arguments it uses273 (`Parameters[i].Evaluate(context)` inside `EvaluateCore`): `case` stops274 at the first true predicate, `contains`/`startsWith`/`endsWith` do not275 evaluate the second argument when the first is not primitive (or, for276 `contains`, not a non-empty array), `join` evaluates the separator only277 with two or more items, `format` evaluates only referenced arguments. And278 `a[b]` (`Index.cs`) evaluates `b` only if `a` is an array or a279 dictionary, and does not wrap its errors (the JS evaluates `b` first and280 wraps them in "could not evaluate index for index access"). So281 `join(success(), format('{2}'))` is `true` in the runner and an error in282 the JS. Built-ins now receive the call and evaluate what they need;283 extension functions still get evaluated arguments.284- [x] **Large integers in `fromJSON`**: found by the differential run. Json.NET285 reads an integer that does not fit in 64 bits as a `BigInteger`, and286 `(Double)BigInteger` truncates toward zero to 53 bits instead of rounding287 to nearest, so `fromJSON('18446744073709551615')` is 2^64-2048, not 2^64,288 and does not equal the literal `18446744073709551615` of an expression289 (which `Double.TryParse` rounds to nearest). Reproduced in290 `jsonParser.number` (`TestFromJSONBigIntegers`); checked on 57 values.291- [x] **Index access**: resolved, same in the C# (`Sdk/Operators/Index.cs`,292 `IndexHelper`): a number is floored, NaN, negatives and numbers above293 Int32.MaxValue give no index; a primitive also has a string form for294 dictionary keys, so booleans and null are `"true"`/`""` there and 1/0 on295 arrays; `' 1 '` and `'1.9'` index element 1. Differences found: the296 index is only evaluated when the object is an array or a dictionary, and297 its errors are not wrapped (see the lazy evaluation item above).298 `TestRunnerSemantics`.299- [x] **Filtered arrays** (`a.*`): resolved, same in the C#300 (`Index.HandleFilteredArray`): `*` on a dictionary yields its values,301 `.*.b` skips elements without `b`, `.*.*` flattens one level, and every302 `*` builds a new array, so `a.* == a.*` is false. `TestRunnerSemantics`.303- [x] **Comparison**: resolved, same in the C# (`EvaluationResult.cs`304 `CoerceTypes`/`AbstractEqual`/`AbstractGreaterThan`): null and booleans305 become numbers when compared with another kind, a string compared with a306 number is parsed with `ParseNumber`, arrays and dictionaries compare by307 reference and are never ordered, NaN is unequal to itself and unordered.308 Strings compare with `OrdinalIgnoreCase` (see above). `TestRunnerSemantics`.309- [x] **`&&`/`||` return an operand**: resolved, same in the C# (`And.cs`,310 `Or.cs`: they return `result.Value` of the deciding operand).311- [x] **Limits**: resolved, follows the C#. Length 21000 UTF-16 units312 (`ExpressionConstants.MaxLength`): "Exceeded max expression length 21000",313 without a token (the JS throws a bare `ErrorExceededMaxLength`). Depth 50314 is checked on the finished tree (`ExpressionParser.CheckMaxDepth`): the315 root is depth 1, an operand of an operator, an argument of a call and the316 two sides of an index are one deeper, **parentheses add nothing**, and a317 chain of `&&` or `||` is one node, nested ones included, so318 `((((...1...))))` with 500 parentheses and `1 || (1 || (1 ...))` parse;319 `"!" * 50 + "true"` and 50 comparisons chained with `==` do not. The error320 has no token: "Exceeded max expression depth 50". The JS limit counted321 parser recursion (parentheses included). Memory: the runner has322 `EvaluationOptions.MaxMemory` (1 MiB by default) and fails with "The323 maximum allowed memory size was exceeded while evaluating the following324 expression: ...", counting strings at 26 bytes + 2 per UTF-16 unit and 24325 per object, per call depth (`EvaluationMemory`). This package keeps its326 own output budget (see above), which is not byte-for-byte the same, so327 the fixture `syntax-errors.json` "memory-errors" is a known difference.328- [x] **Trailing comma in calls**: resolved, follows the C#. `contains('a', 'b',)`329 is "Unexpected symbol: ')'" (the tokenizer rejects a `)` after a `,`). The JS330 accepted it. `f(,)` and `contains(,'a')` fail as before.331- [x] **Whitespace**: resolved, follows the C#. `Char.IsWhiteSpace` separates332 tokens: U+0009..U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000..U+200A,333 U+2028, U+2029, U+202F, U+205F, U+3000. So `1 \\u00a0 2` is "Unexpected334 symbol: '2'" (not the NBSP), and `\\v1` is 1. The JS skipped only space,335 tab, CR and LF. U+FEFF is not white space.336- [x] **Empty expression**: same in the C# (`CreateTree` returns null when there337 is no token). `Parse` returns a nil Node and no error; `Evaluate(nil)`338 errors; callers (the template layer) decide.339- [x] **Unknown contexts at evaluation time** are null: in the runner a340 declared named value is evaluated by the caller's node; here an absent341 key of `EvalContext.Contexts` is null.342- [x] **Error positions**: resolved, follows the C#. A `ParseException` names343 the token and its **absolute 1-based index in the expression**344 (`Located at position N within expression: <whole expression>`);345 `Error.Pos` is the same place as a zero-based line and column.346 Arity and even-argument errors name the `(` of the call (the token347 `FlushTopEndParameters` holds), not the function: `contains()` is348 "Too few parameters supplied: '('. Located at position 9". The349 end-of-input error is "Unexpected end of expression: '<last token>'"350 (the JS said "Unexpected symbol: 'EOF'"), and the JS's negative EOF column351 is gone with the JS lexer. Unexpected-symbol tokens are the352 boundary-delimited run (`ReadKeywordToken`): `github..event` reports `.`353 where the JS reported `.event`. Lexing and parsing are interleaved, so the354 first problem in reading order wins (the JS lexed everything first).355 `TestParseErrorPositions`, and `TestRunnerOracle` on the 1 715 parse errors of the corpus (identical message, position included).356 One fixture still differs, see `xlangKnownDifferences`: `case/27` expects357 the name for an even `case()`, the runner passes the `(`.358- [x] **Function names and context names are case-insensitive**: same in the359 C# (`StringComparer.OrdinalIgnoreCase` dictionaries; see the folding360 item). Built-ins win over extension functions of the same name at361 validation **and at evaluation** (`ExpressionParser.TryGetFunctionInfo`362 looks in `ExpressionConstants.WellKnownFunctions` first and builds the363 node from it); the JS let an extension override a built-in at364 evaluation. An extension supplied under a built-in's name is now never365 called.366367## JavaScript behaviours deliberately NOT ported368369These are accidents of JavaScript objects used as maps; porting them would be370a bug.371372- `Dictionary.add` tests `key.toLowerCase() in this.indexMap` on a plain373 object, so keys that collide with `Object.prototype` properties374 (`constructor`, `__proto__`, `valueOf`, ...) are silently dropped or hidden.375 Here every key is stored.376- `wellKnownFunctions[name]` is a plain-object lookup, so `constructor(1)`,377 `toString(1)` and `hasOwnProperty(1)` pass validation or crash with a378 `TypeError` instead of "Unrecognized function". Here they are "Unrecognized379 function".380- A `TypeError` in the JS (`Cannot read properties of undefined`) when381 evaluating e.g. `github.true` with an empty context: the reference does not382 handle a missing root context. Here it is null.383- Recursion limits: JS recursion depth for `fromJSON` input is engine384 dependent. `FromJSON` follows Json.NET's `MaxDepth` of 64.385- Lone surrogates: JS strings are UTF-16 and can hold them. .NET strings can386 too, but no value of an expression has one: Json.NET reads an unpaired387 `\ud800` escape as U+FFFD (checked: `fromJSON('"\ud800"') == '\ufffd'` is388 true), and Go strings cannot hold them either. The WTF-8 helpers of the first389 version (`wtf8Surrogate`, `normalizeWTF8` in `casing.go`) are only reached390 through `Lex`-era code and the tests.391392## Still open393394- [ ] **Memory accounting.** The runner caps an evaluation at `MaxMemory` (1 MiB)395 counted by `EvaluationMemory`/`MemoryCounter` (26 bytes + 2 per UTF-16 unit396 per string, 24 per object, trimmed per call depth); this package caps397 `MaxResultBytes` (1 MiB of output, different units) and accepts398 `format(...)` chains the runner rejects. Fixture399 `syntax-errors/memory-errors/0` is the known difference. Reproducing400 `EvaluationMemory` exactly would need the node depth of every value;401 worth doing only if the error text matters ("The maximum allowed memory402 size was exceeded while evaluating the following expression: ...", which403 needs `ConvertToExpression` of the root, also not ported).404- [x] **Negative zero**: `-0` is `-0`, as the legacy engine that runs today405 (`DTExpressions2/EvaluationResult.ConvertToString` formats with `G15` and406 has no special case; .NET Core writes `-0`): `format('{0}', -0)`, `-0`407 as a result and `toJSON(-0)` (`toJSON` uses `ConvertToString`). A JSON408 integer token `-0` or `-00` is read as the long 0, but `-0.0` keeps its409 sign. The newer engine (`Expressions/EvaluationResult.cs`, "keep our410 behavior consistent") maps it to `0`, and so do these shared fixtures:411 `coerce_string/number#10-11`, `format/format#19-20` and412 `tojson/basics#4` (5 cases) are listed in `xlangKnownDifferences` with this413 evidence. `engines.mjs` shows it is the only result on which the engines414 differ. **Track the runner's switch to `src/Sdk/Expressions`415 (`TemplateEvaluatorMismatch` telemetry); re-run416 `gen/runner-new/engines.mjs` on each runner release.** If it switches,417 change `numberToCoercedString` back to `"0"` (one line) and the tests418 that mention `-0`.419- [ ] **Comments and constructors in `fromJSON`.** Json.NET accepts `/* */` and420 `//` comments and `new Date(1)` constructors; the first is read in a421 surprising way (`/*x*/1` is the string `x`). They fail here.422- [ ] **`fromJSON` error texts.** Json.NET messages ("Unexpected character423 encountered while parsing value: a. Path '', line 0, position 0.") are424 not reproduced; the outer message "Error parsing JSON when evaluating425 fromJson" is the JS one, the runner's exception message is the Json.NET426 one. Only the failure is checked.427- [ ] **`StrictJsonParsing`.** The newer engine has an option that switches428 `fromJSON` to `System.Text.Json` (strict, 64 levels, different429 messages); default off, "temporary for telemetry". Not implemented.430- [ ] **Unicode version of the folding table** is that of the .NET 9 runtime that431 generated `fold_tables.go`; a runner on another runtime may fold newly432 assigned letters differently.433- [ ] **Tracing and secret masking** (`ITraceWriter`, `ISecretMasker`,434 `EvaluationResult` tracing) are runner features outside this package.435436## Left to the callers437438- Run the runner's expression test vectors: done for `ExpressionParserL0` and439 `ConditionFunctionsL0` (`runner_l0_test.go`). The runner has no other440 expression tests in `src/Test/L0` (the larger suites are in the closed441 service); the cross-language fixtures cover the rest.442- The functions the runner implements natively with special semantics:443 `success()`, `failure()`, `always()`, `cancelled()` (no arguments; read job444 status, or `action_status` in a composite action's main step) and445 `hashFiles()` (variadic, string result) are supplied by the caller through446 `FuncInfo` + `Func`; `runner_l0_test.go` shows the declaration the runner447 uses (`FunctionInfo(name, 0, 0)`).448- Context names per location and which functions each location may call belong449 to the template layer.450- The `format()` argument limit is 255 in both (`Byte.MaxValue`).451- Secret masking and the `${{ }}` splitting of a larger string are not part of452 this package: `Parse` takes the text between the braces only.