v0.1.0 28f9d6e

PORTING.md RawHistoryFolder

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.

  • 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.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.
  • == 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.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.
  • &&/|| 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 expr
2
3The package implements the expression language of GitHub Actions as **the C#
4runner** (actions/runner, `src/Sdk/DTExpressions2`) runs it on github.com. It
5started as a port of `@actions/expressions` 0.3.61 (MIT, GitHub,
6actions/languageservices), the JavaScript used by the editors, and was then
7checked against the runner. Where the two differ the runner wins, because it
8is 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 for
10the token stream and positions of `Lex`, which `Parse` no longer uses.
11
12Three kinds of evidence back the tests:
13
14- `testdata/oracle.json.gz`: what the JavaScript answers on 34 765 expressions
15  (tokens, error messages, result kinds, string forms, `toJSON`), generated by
16  `gen/oracle.mjs`; `TestOracle` replays it, and excuses a mismatch only where
17  the runner gives this package's answer.
18- `testdata/runner.json.gz`: what the runner's own code answers on the same
19  expressions. `gen/runner` compiles `src/Sdk/DTExpressions2` (plus stubs for
20  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 same
22  result, string form, `toJSON` text and, for errors, the same message (parse
23  errors included, to the position); the only exception is the message text of
24  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 random
27  doubles.
28- `testdata/languageservices/*.json`: the 1 020 cross-language fixtures of
29  actions/languageservices (see `xlang_test.go`): 1 013 pass, 7 are known
30  differences with a reason: negative zero (5 cases, below), `case/27` (the runner reports an even `case()` on
31  the `(`, not the name) and `syntax-errors/memory-errors/0` (the runner's
32  memory accounting). `testdata/languageservices/SOURCE` has the commit.
33
34The runner's own expression tests (`ExpressionParserL0`, `ConditionFunctionsL0`)
35are ported in `runner_l0_test.go`; they are the only tests of the expression
36engine in the runner's `src/Test/L0` (the other files that mention it test
37templates 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; the
39code `Parse` and `Evaluate` use for strings is `ordinal.go`, `number.go` and
40`json.go`. The WTF-8 helpers in `casing.go` are only reached through `Lex` and
41tests.
42
43Regenerating: see `gen/runner/README.md`. The scripts need a clone of
44actions/runner at the commit `5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798`
45(`gen/runner/runner-commit.txt`, asserted by the scripts and by
46`TestRunnerCommit`), the .NET SDK and a 9.0.x runtime (recorded: 9.0.20); none of
47it is needed by `go test`.
48
49### The two engines of the runner, and which one runs
50
51The 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` and
55options such as `StrictJsonParsing`). They are separate implementations, almost
56line for line the same.
57
58- **Which one runs.** The legacy one. `Runner.Worker/PipelineTemplateEvaluatorWrapper.cs`
59  (used by `ExecutionContext.ToPipelineTemplateEvaluator`) and
60  `ActionManifestManagerWrapper.cs` call `EvaluateAndCompare(legacy, new, ...)`:
61  both evaluators run, the **legacy result (or its exception) is returned**, and a
62  difference is only reported as job telemetry (`TemplateEvaluatorMismatch`,
63  `RecordMismatch`). So the newer engine is a shadow being validated, and what
64  users see is the legacy behaviour. This is why `gen/runner` builds the
65  legacy engine.
66- **How they differ.** `gen/runner-new` builds the newer one with the same driver
67  and `gen/runner-new/engines.mjs` runs 46 148 expressions through both (the
68  34 765 of the oracle, the 1 791 `fromJSON` inputs, the number, comparison,
69  `contains`, `startsWith` and `endsWith` corpora): the results are identical
70  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 "Error
73  parsing fromJson"; both are `JsonReaderException`). In the sources the
74  newer engine also has options the legacy one lacks: `StrictJsonParsing` (a
75  `System.Text.Json` path in `fromJSON`, off by default, "temporary for
76  telemetry"), `AlwaysTraceExpanded` and `MaxCacheMemory` (tracing), and its
77  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 shared
80  cross-language fixtures and this package agree against the legacy one; if
81  the runner switches engines nothing else changes. See "Still open".
82
83## Limits added by this port (not in the JavaScript or the runner)
84
85The JavaScript has no bound on what an evaluation may build. These are
86additions, each reported as an `*EvalError` (or `*Error` for the parser) and
87none changes the result of an evaluation that stays within them.
88
89- **Output budget** (`MaxResultBytes`, 1 MiB by default, `EvalContext.
90  MaxResultBytes` to override, negative to disable). One budget per
91  `Evaluate`, charged by `format` (every piece appended), `join` (items and
92  separators), `toJSON` (the encoder stops as soon as the output would exceed
93  what is left) and by every element of the arrays built by `.*` / `[*]`
94  (16 bytes each). `format('{0}{0}', format('{0}{0}', ...))` chains and
95  `a.*.*.*` fan-outs over shared arrays are cut off instead of exhausting
96  memory. Strings that come out of `fromJSON` are not charged: their size is
97  bounded by the input the caller provided. The cost per filtered element is a
98  guess; revisit it if real workflows hit the limit.
99- **Cancellation**: `Evaluate(ctx, ...)` checks `ctx` every 64 nodes and in the
100  loops of filters, `join` and `contains`, and passes `ctx` to extension
101  functions.
102- **Evaluation depth** of 1000 for hand-built trees (parsed trees stay far
103  below it), **JSON depth** `MaxJSONDepth` = 1000 for the
104  encoder (`FromJSON` is limited to 64 levels like Json.NET) (cyclic values built with `Array.Append` yield an error from
105  `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 an
108  `*EvalError`, not a panic.
109- **Input length** is checked before the input is converted, without
110  allocating.
111
112Limits that the runner has and this package follows: expression length 21000
113UTF-16 units and tree depth 50 (see below).
114
115## Performance notes (deferred)
116
117- GO-008: `Evaluate` rebuilds the case-folded context and function maps on
118  every call, and `foldKey` lower-cases on each lookup. Fine for per-step
119  evaluation; when a caller evaluates thousands of expressions against the
120  same contexts, add a prepared `Environment` (fold once, reuse) and cache
121  folded names in the AST.
122
123## Where the JavaScript differs from the C# runner (and what this package does)
124
125Each item names the runner source that decides it. All are resolved; the
126package follows the runner.
127
128- [x] **String escapes: every `''` is unescaped.** Resolved: follows the C#.
129      `LexicalAnalyzer.ReadStringToken` (`Tokens/LexicalAnalyzer.cs`, the
130      "Escaped single quote" branch) skips the second quote of each pair and
131      appends the first, so `'a''b''c'` is `a'b'c`. The JavaScript lexer does
132      `value.replace("''", "'")` (first pair only) and gives `a'b''c`; that is
133      a bug of the editor library. Tests: `TestEvalLiteralsAndOperators`; the
134      oracle excuses the JS mismatch because `testdata/runner.json.gz` agrees
135      with this package.
136- [x] **`case()` availability.** Resolved: the runner has `case` always
137      (`ExpressionConstants`: `AddFunction<Case>("case", 3, Byte.MaxValue)`),
138      with an odd number of arguments enforced at parse time
139      (`ExpressionParser.FlushTopEndParameters`: `EvenParameters`, reported on
140      the `(` token) and at evaluation (`Sdk/Functions/Case.cs`), predicates
141      must be booleans ("case predicate must evaluate to a boolean value"), and
142      only the matching branch is evaluated. `ExpressionParserL0` has a
143      regression test that `case(...)` must not turn on "allow unknown
144      keywords" (ported in `runner_l0_test.go`). Here it is always on, with
145      the maximum of 255 arguments (the JS has no maximum).
146- [x] **`format()`**: resolved, follows the C#. `Sdk/Functions/Format.cs`: the
147      format string is scanned first (syntax and "more arguments than were
148      supplied" are reported before any argument is evaluated), arguments are
149      then evaluated lazily, once each. The index is a **byte**
150      (`Byte.TryParse`): `{255}` is the last valid index, `{256}` and
151      `{12345678901234567890123}` are "format string is invalid", not "more
152      arguments". After the digits only `}` or `:specifiers}` are allowed
153      (`{0 }`, `{0,5}` are invalid); **specifiers are an error** for every
154      value: `The format specifiers 'x' are not valid for objects of type
155      '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 use
161      `OrdinalIgnoreCase`.** Resolved: follows the C#. Every string comparison
162      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 keys
168      (`DictionaryContextData.IndexLookup`)). That folds one code point at a
169      time with a simple mapping: `ß` is not `SS`, `ı` and `İ` and `ſ` and the
170      Kelvin sign are not `i`/`I`/`s`/`k`, `ς` and `σ` are equal, `Dž` is `dž`.
171      `contains()` no longer lower-cases (JS) and `startsWith`/`endsWith` no
172      longer upper-case with full mappings (JS). Ordering (`<`, `>`) is by
173      code point after folding, so a supplementary character sorts after
174      U+E000..U+FFFF (JS orders UTF-16 units). `ordinal.go` implements it;
175      `fold_tables.go` is generated from the runner's behaviour
176      (`go generate`, `gen/runner --fold`), and
177      `TestOrdinalFoldMatchesRunner` replays 1 564 pairs recorded from .NET.
178      The table is the one of the .NET runtime that produced it (9.0); the
179      runner ships its own runtime, so a different Unicode version could differ
180      on newly assigned letters.
181- [x] **`==` on strings, `<`, `>`**: see the previous item (same folding).
182- [x] **Number to string (`CoerceString`)**: resolved, follows the C#. The
183      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, positional
187      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` is
191      `Infinity`. Negative zero is `-0` (see the Negative zero item below). Checked against the
192      runner's code on 200 000 random doubles. The number *literal* in an
193      expression is parsed independently of this (see below) and the JSON
194      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 and
201      `0o` octal (lower-case prefix only, no sign) read as a **32-bit signed
202      integer** (`0xffffffff` is `-1`, anything over 32 bits is NaN). There is
203      no `0b` form and no upper-case `0X`/`0O`. The lexer uses the same
204      function for number literals, so `0b101`, `0X10` and `0xFFFFFFFFF` are
205      "Unexpected symbol" errors and `0xffffffff` is `-1`. The same function is
206      implemented as `parseNumber` in `number.go`;
207      `TestParseNumberMatchesRunner` replays 443 strings recorded from the
208      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 keep
214      the document order (`JTokenExtensions.ToPipelineContextData` iterates
215      `JObject.Properties()` into `DictionaryContextData`, a list); the JS
216      `Object.keys` order (array-index keys first) is gone, from `FromJSON` and
217      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 the
221      position and with the spelling of the first. `{"a":1,"A":2}` is
222      `{"a": 2}` (the JS keeps `1`). Json.NET itself also merges exact
223      duplicates (last wins).
224- [x] **`fromJSON` errors and grammar**: resolved, follows the C#. The reader is
225      Json.NET's (`Sdk/Functions/FromJson.cs`: `JToken.ReadFrom` on a
226      `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`, raw
230      control characters in strings, anything after the first value, and
231      `MaxDepth` 64. A comma where the document should start (`,1`) is an
232      undefined value, `""`. **Leading zeros are octal**: a non-negative
233      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 decimal
237      (`-010` is -10, `-01.5` is -1.5). An unpaired surrogate escape becomes
238      U+FFFD. Blank input is a `JsonReaderException` ("Error reading JToken from
239      JsonReader."). **Error texts**: a failure is a `*JSONError` whose text is
240      the Json.NET message GitHub prints, `<reason> Path '<path>', line <n>,
241      position <n>.` (`not json` is `Unexpected character encountered while
242      parsing value: n. Path '', line 0, position 0.`). Reasons, paths and
243      positions are ported from `JsonTextReader` and checked against the runner
244      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 are
247      not reproduced (the message of those may differ in reason or position). Not ported: `/* */` and `//` comments (the runner
248      reads `/*x*/1` as the string `x`) and `new Date(1)` constructors; both are
249      errors here. A `/` after `null`, `true`, `false`, `NaN`, `Infinity`,
250      `-Infinity` or `undefined`, and a `/` between values inside an array or
251      object, is an error here as in the runner (where it starts a comment or is
252      an error); after a number or a string at the top level the rest is ignored
253      in both. Checked on 1 791 inputs recorded from the runner
254      (`testdata/fromjson_cases.json`, hand picked and seeded mutations; the
255      24 that contain a `/` or `new ` are skipped by the test), also used as
256      fuzz seeds.
257- [x] **`toJSON`**: resolved, follows the C#. `Sdk/Functions/ToJson.cs` builds
258      the text itself: two-space indentation, `[]`/`{}` for empties, numbers
259      through `ConvertToString` (so `NaN`, `Infinity` and `-Infinity` appear
260      bare, `1E+21`, 15 significant digits, `-0` is `-0`) and strings through
261      `JsonUtility.ToString` (Json.NET defaults: `\b \f \n \r \t`, `\u00XX`
262      for other controls, `\u0085 \u2028 \u2029`, no escaping of `< > & '`, and
263      non-ASCII as is). Checked against the runner's code
264      (`testdata/tojson/*.runner`). The JS `null` for non-finite numbers, the
265      shortest round-trip form and the raw U+2028 are not followed.
266      Caveat: `JsonUtility` is stubbed in `gen/runner/Stubs.cs` with
267      `JsonConvert.SerializeObject`; the real one passes the runner's
268      `VssJsonMediaTypeFormatter` settings, which do not change string
269      escaping.
270- [x] **Lazy evaluation of built-in function arguments and of index
271      expressions**: found by the differential run, not in the original list.
272      In the runner a function evaluates only the arguments it uses
273      (`Parameters[i].Evaluate(context)` inside `EvaluateCore`): `case` stops
274      at the first true predicate, `contains`/`startsWith`/`endsWith` do not
275      evaluate the second argument when the first is not primitive (or, for
276      `contains`, not a non-empty array), `join` evaluates the separator only
277      with two or more items, `format` evaluates only referenced arguments. And
278      `a[b]` (`Index.cs`) evaluates `b` only if `a` is an array or a
279      dictionary, and does not wrap its errors (the JS evaluates `b` first and
280      wraps them in "could not evaluate index for index access"). So
281      `join(success(), format('{2}'))` is `true` in the runner and an error in
282      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.NET
285      reads an integer that does not fit in 64 bits as a `BigInteger`, and
286      `(Double)BigInteger` truncates toward zero to 53 bits instead of rounding
287      to nearest, so `fromJSON('18446744073709551615')` is 2^64-2048, not 2^64,
288      and does not equal the literal `18446744073709551615` of an expression
289      (which `Double.TryParse` rounds to nearest). Reproduced in
290      `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 above
293      Int32.MaxValue give no index; a primitive also has a string form for
294      dictionary keys, so booleans and null are `"true"`/`""` there and 1/0 on
295      arrays; `' 1 '` and `'1.9'` index element 1. Differences found: the
296      index is only evaluated when the object is an array or a dictionary, and
297      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 every
302      `*` 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 booleans
305      become numbers when compared with another kind, a string compared with a
306      number is parsed with `ParseNumber`, arrays and dictionaries compare by
307      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 units
312      (`ExpressionConstants.MaxLength`): "Exceeded max expression length 21000",
313      without a token (the JS throws a bare `ErrorExceededMaxLength`). Depth 50
314      is checked on the finished tree (`ExpressionParser.CheckMaxDepth`): the
315      root is depth 1, an operand of an operator, an argument of a call and the
316      two sides of an index are one deeper, **parentheses add nothing**, and a
317      chain of `&&` or `||` is one node, nested ones included, so
318      `((((...1...))))` with 500 parentheses and `1 || (1 || (1 ...))` parse;
319      `"!" * 50 + "true"` and 50 comparisons chained with `==` do not. The error
320      has no token: "Exceeded max expression depth 50". The JS limit counted
321      parser recursion (parentheses included). Memory: the runner has
322      `EvaluationOptions.MaxMemory` (1 MiB by default) and fails with "The
323      maximum allowed memory size was exceeded while evaluating the following
324      expression: ...", counting strings at 26 bytes + 2 per UTF-16 unit and 24
325      per object, per call depth (`EvaluationMemory`). This package keeps its
326      own output budget (see above), which is not byte-for-byte the same, so
327      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 JS
330      accepted it. `f(,)` and `contains(,'a')` fail as before.
331- [x] **Whitespace**: resolved, follows the C#. `Char.IsWhiteSpace` separates
332      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 "Unexpected
334      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 there
337      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 a
340      declared named value is evaluated by the caller's node; here an absent
341      key of `EvalContext.Contexts` is null.
342- [x] **Error positions**: resolved, follows the C#. A `ParseException` names
343      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 token
347      `FlushTopEndParameters` holds), not the function: `contains()` is
348      "Too few parameters supplied: '('. Located at position 9". The
349      end-of-input error is "Unexpected end of expression: '<last token>'"
350      (the JS said "Unexpected symbol: 'EOF'"), and the JS's negative EOF column
351      is gone with the JS lexer. Unexpected-symbol tokens are the
352      boundary-delimited run (`ReadKeywordToken`): `github..event` reports `.`
353      where the JS reported `.event`. Lexing and parsing are interleaved, so the
354      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` expects
357      the name for an even `case()`, the runner passes the `(`.
358- [x] **Function names and context names are case-insensitive**: same in the
359      C# (`StringComparer.OrdinalIgnoreCase` dictionaries; see the folding
360      item). Built-ins win over extension functions of the same name at
361      validation **and at evaluation** (`ExpressionParser.TryGetFunctionInfo`
362      looks in `ExpressionConstants.WellKnownFunctions` first and builds the
363      node from it); the JS let an extension override a built-in at
364      evaluation. An extension supplied under a built-in's name is now never
365      called.
366
367## JavaScript behaviours deliberately NOT ported
368
369These are accidents of JavaScript objects used as maps; porting them would be
370a bug.
371
372- `Dictionary.add` tests `key.toLowerCase() in this.indexMap` on a plain
373  object, so keys that collide with `Object.prototype` properties
374  (`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 a
378  `TypeError` instead of "Unrecognized function". Here they are "Unrecognized
379  function".
380- A `TypeError` in the JS (`Cannot read properties of undefined`) when
381  evaluating e.g. `github.true` with an empty context: the reference does not
382  handle a missing root context. Here it is null.
383- Recursion limits: JS recursion depth for `fromJSON` input is engine
384  dependent. `FromJSON` follows Json.NET's `MaxDepth` of 64.
385- Lone surrogates: JS strings are UTF-16 and can hold them. .NET strings can
386  too, but no value of an expression has one: Json.NET reads an unpaired
387  `\ud800` escape as U+FFFD (checked: `fromJSON('"\ud800"') == '\ufffd'` is
388  true), and Go strings cannot hold them either. The WTF-8 helpers of the first
389  version (`wtf8Surrogate`, `normalizeWTF8` in `casing.go`) are only reached
390  through `Lex`-era code and the tests.
391
392## Still open
393
394- [ ] **Memory accounting.** The runner caps an evaluation at `MaxMemory` (1 MiB)
395      counted by `EvaluationMemory`/`MemoryCounter` (26 bytes + 2 per UTF-16 unit
396      per string, 24 per object, trimmed per call depth); this package caps
397      `MaxResultBytes` (1 MiB of output, different units) and accepts
398      `format(...)` chains the runner rejects. Fixture
399      `syntax-errors/memory-errors/0` is the known difference. Reproducing
400      `EvaluationMemory` exactly would need the node depth of every value;
401      worth doing only if the error text matters ("The maximum allowed memory
402      size was exceeded while evaluating the following expression: ...", which
403      needs `ConvertToExpression` of the root, also not ported).
404- [x] **Negative zero**: `-0` is `-0`, as the legacy engine that runs today
405      (`DTExpressions2/EvaluationResult.ConvertToString` formats with `G15` and
406      has no special case; .NET Core writes `-0`): `format('{0}', -0)`, `-0`
407      as a result and `toJSON(-0)` (`toJSON` uses `ConvertToString`). A JSON
408      integer token `-0` or `-00` is read as the long 0, but `-0.0` keeps its
409      sign. The newer engine (`Expressions/EvaluationResult.cs`, "keep our
410      behavior consistent") maps it to `0`, and so do these shared fixtures:
411      `coerce_string/number#10-11`, `format/format#19-20` and
412      `tojson/basics#4` (5 cases) are listed in `xlangKnownDifferences` with this
413      evidence. `engines.mjs` shows it is the only result on which the engines
414      differ. **Track the runner's switch to `src/Sdk/Expressions`
415      (`TemplateEvaluatorMismatch` telemetry); re-run
416      `gen/runner-new/engines.mjs` on each runner release.** If it switches,
417      change `numberToCoercedString` back to `"0"` (one line) and the tests
418      that mention `-0`.
419- [ ] **Comments and constructors in `fromJSON`.** Json.NET accepts `/* */` and
420      `//` comments and `new Date(1)` constructors; the first is read in a
421      surprising way (`/*x*/1` is the string `x`). They fail here.
422- [ ] **`fromJSON` error texts.** Json.NET messages ("Unexpected character
423      encountered while parsing value: a. Path '', line 0, position 0.") are
424      not reproduced; the outer message "Error parsing JSON when evaluating
425      fromJson" is the JS one, the runner's exception message is the Json.NET
426      one. Only the failure is checked.
427- [ ] **`StrictJsonParsing`.** The newer engine has an option that switches
428      `fromJSON` to `System.Text.Json` (strict, 64 levels, different
429      messages); default off, "temporary for telemetry". Not implemented.
430- [ ] **Unicode version of the folding table** is that of the .NET 9 runtime that
431      generated `fold_tables.go`; a runner on another runtime may fold newly
432      assigned letters differently.
433- [ ] **Tracing and secret masking** (`ITraceWriter`, `ISecretMasker`,
434      `EvaluationResult` tracing) are runner features outside this package.
435
436## Left to the callers
437
438- Run the runner's expression test vectors: done for `ExpressionParserL0` and
439  `ConditionFunctionsL0` (`runner_l0_test.go`). The runner has no other
440  expression tests in `src/Test/L0` (the larger suites are in the closed
441  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 job
444  status, or `action_status` in a composite action's main step) and
445  `hashFiles()` (variadic, string result) are supplied by the caller through
446  `FuncInfo` + `Func`; `runner_l0_test.go` shows the declaration the runner
447  uses (`FunctionInfo(name, 0, 0)`).
448- Context names per location and which functions each location may call belong
449  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 of
452  this package: `Parse` takes the text between the braces only.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close