detached 8798092

docs(expr): list what remains to check against the C# runner

commit
879809200fe4e7a5f2e644616ff27058ce7b4f17
author
brice · · 2026-10-12 14:46 UTC
parent
c82ace7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +153 -0
A expr/PORTING.md +153 -0
@@ -0,0 +1,153 @@
1+# Porting notes for package expr
2+
3+The package is a port of `@actions/expressions` 0.3.61 (MIT, GitHub,
4+actions/languageservices). The JavaScript is the specification wherever this
5+package could be checked against it: `testdata/oracle.json.gz` holds what the
6+reference answers for 33 575 expressions (tokens with positions, error
7+messages, result kinds, string coercions, `toJSON` output), and
8+`TestOracle` replays it. `gen/oracle.mjs` regenerates it, `gen/golden.mjs` the
9+`testdata/tojson` goldens, and `gen/casing_dump.mjs` + `gen/casing.go` the
10+casing tables (Node 25 / V8 14.1, Unicode 16; Go 1.27 ships Unicode 15).
11+
12+The C# runner (actions/runner, `DistributedTask/Expressions2`) is the
13+implementation that runs on github.com. It was not available while this was
14+written. Nothing below has been checked against it.
15+
16+## Where the JavaScript is believed to differ from the C# runner
17+
18+Each item is implemented as the JavaScript does. Check the runner's tests
19+(`src/Test/L0/Expressions*`, `Sdk/Expressions2/...`) and decide for each one
20+whether to keep the JS behaviour or follow the runner.
21+
22+- [ ] **String escapes: only the first `''` is unescaped.** The lexer does
23+ `value.replace("''", "'")` with a string pattern, which replaces one
24+ occurrence: `'a''b''c'` is `a'b''c`. The C# lexer should unescape every
25+ pair (`StringBuilder`/`Replace`). Probable JS bug. (`lexer.go`,
26+ `consumeString`; tests: `TestEvalLiteralsAndOperators`.)
27+- [ ] **`case()` availability.** The JS gates it behind the experimental flag
28+ `allowCaseFunction`, but only in completion; the parser and the
29+ evaluator always accept it. Here it is always on. Confirm whether the
30+ hosted service accepts `case` and with which arity rules (the JS: at
31+ least 3 arguments, an odd number, predicates must be booleans).
32+- [ ] **`format()`**: the reference uses `parseInt` on the digits, so
33+ `{99999999999999999999}` is an Infinity-like index and reports "more
34+ arguments than were supplied". The `{0:x}` form silently ignores the
35+ specifier; C# may throw. The error texts are the JS ones. Check the C#
36+ `Format` node for the exact messages and for `{N}` with `N` > 255 or
37+ leading zeros.
38+- [ ] **`contains()` lower-cases (`toLowerCase`), `startsWith`/`endsWith`
39+ upper-case (`toUpperSpecial`).** The C# uses `OrdinalIgnoreCase`
40+ comparisons, which differ for `ß`, `İ`, `ı`, the Kelvin sign and final
41+ sigma. `contains('ß', 'ss')` is false in JS; `startsWith('ß', 'ss')` is
42+ true. Check what the runner does.
43+- [ ] **`==` on strings uses `toUpperSpecial`** (upper-case with the dotless
44+ `ı` left alone, full Unicode mapping so `ß == 'SS'`), and `<`/`>` compare
45+ the upper-cased UTF-16 code units. C# `OrdinalIgnoreCase` upper-cases
46+ per UTF-16 code unit with simple mappings only: `ß == 'SS'` should be
47+ false there.
48+- [ ] **Number to string (`CoerceString`)**: `(+x.toFixed(15)).toString()`.
49+ C# formats a `double` with `"G15"`-like rules. They agree for ordinary
50+ values; check very small and very large numbers (`1e-7` here is `1e-7`,
51+ `1e21` is `1e+21`), `-0`, and 16-17 significant digit values.
52+- [ ] **String to number**: `Number()` semantics (hex, octal, binary,
53+ `Infinity`, whitespace trimmed, empty is 0). C# `double.TryParse` with
54+ `NumberStyles` differs (no octal/binary; check `0x` and `Infinity`).
55+ The lexer applies the same conversion to number literals, so `0x10`,
56+ `0b11`, `1e3`, `.5`, `5.` and `+1` are all literals here.
57+- [ ] **Number literals after `-` / `+`**: `-1` is one literal token; `a -1`
58+ is therefore a parse error, as in the JS.
59+- [ ] **Object key order from `fromJSON`**: array-index keys first, ascending
60+ (JavaScript `Object.keys`), then insertion order. `Json.NET` keeps
61+ document order. The same ordering is applied by `toJSON` (the JS
62+ replacer builds a plain object). Check against the runner for
63+ `toJSON(fromJSON('{"b":1,"1":2}'))`.
64+- [ ] **Duplicate JSON keys**: last value wins at the first position (JS);
65+ keys that differ only by case collapse to the first.
66+- [ ] **`fromJSON` errors**: the JS message is
67+ `Error parsing JSON when evaluating fromJson` with the V8 `SyntaxError`
68+ as cause; here the cause is a Go error with similar but not identical
69+ wording (only the outer message is stable). `empty input` for blank
70+ input. C# messages differ; check.
71+- [ ] **`toJSON`**: `JSON.stringify(v, replacer, " ")`: two-space indent,
72+ `[]` and `{}` for empties, `NaN`/`Infinity` as `null`, `-0` as `0`,
73+ no escaping of `<`, `>`, `&`, U+2028/U+2029; lone surrogates as
74+ `\udXXX`. Json.NET differs on some of these (`Infinity`, `-0`, escapes).
75+- [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and
76+ rejects NaN, negatives and infinities; where `b` is a string it is a key
77+ or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array).
78+ Booleans and null index as `1`/`0` on arrays and as `"true"`/`""` keys on
79+ dictionaries.
80+- [ ] **Filtered arrays** (`a.*`): a dictionary filtered by `*` yields its
81+ values; `.*.b` skips elements without `b`; `.*.*` flattens one level;
82+ arrays produced by `*` are fresh, so `a.* == a.*` is false.
83+- [ ] **Comparison**: `null` is only ordered against numbers/booleans (it
84+ becomes 0); arrays and dictionaries are never ordered and `==` is
85+ identity; `NaN` is unordered and unequal to itself; `true > false`.
86+- [ ] **`&&`/`||` return an operand**, not a boolean (`'' || 'x'` is `'x'`).
87+- [ ] **Limits**: max expression length 21000 (UTF-16 units; the JS throws a
88+ bare `Error("ErrorExceededMaxLength")`), max parse depth 50. The depth
89+ counting (including `index()` and the flat logical chains) is ported
90+ literally; C# `MaxDepth` is 50 with its own counting. Cross-check with
91+ deep-nesting tests. There is no node-count or evaluation-memory limit
92+ here; the C# runner has `MaxMemory` (1 MB) and a result-size limit.
93+- [ ] **Trailing comma in calls**: `contains('a', 'b',)` parses (the JS loop
94+ only requires a comma when the next token is not `)`). `f(,)` does not.
95+- [ ] **Whitespace**: only space, `\t`, `\r`, `\n` separate tokens. U+00A0 and
96+ other Unicode spaces end a word but are not skipped, so `1 \u00a0 2`
97+ reports an unexpected symbol. `\v`, `\f` likewise.
98+- [ ] **Empty expression**: the JS parser returns `undefined` for no tokens.
99+ `Parse` returns a nil Node and no error; `Evaluate(nil)` errors.
100+- [ ] **Unknown contexts at evaluation time** are null (the JS `Dictionary.get`
101+ returns `undefined` and `visitContextAccess` then crashes later); here
102+ declared-but-absent contexts are null, never a crash.
103+- [ ] **Error positions**: the JS EOF token's start column is negative after a
104+ trailing newline (stale `start`); ported as is
105+ (`TestEOFTokenPosition`).
106+- [ ] **Function names and context names are case-insensitive**; the JS
107+ lower-cases with `toLowerCase`, as does this port (`foldKey`). Extension
108+ functions override built-ins at evaluation (`functions?.get(...) ||
109+ wellKnownFunctions[...]`) but built-ins win at validation time (arity).
110+
111+## JavaScript behaviours deliberately NOT ported
112+
113+These are accidents of JavaScript objects used as maps; porting them would be
114+a bug.
115+
116+- `Dictionary.add` tests `key.toLowerCase() in this.indexMap` on a plain
117+ object, so keys that collide with `Object.prototype` properties
118+ (`constructor`, `__proto__`, `valueOf`, ...) are silently dropped or hidden.
119+ Here every key is stored.
120+- `wellKnownFunctions[name]` is a plain-object lookup, so `constructor(1)`,
121+ `toString(1)` and `hasOwnProperty(1)` pass validation or crash with a
122+ `TypeError` instead of "Unrecognized function". Here they are "Unrecognized
123+ function".
124+- A `TypeError` in the JS (`Cannot read properties of undefined`) when
125+ evaluating e.g. `github.true` with an empty context: the reference does not
126+ handle a missing root context. Here it is null.
127+- Recursion limits: JS recursion depth for `fromJSON` input is engine
128+ dependent (about 5 000 to 10 000 levels). `FromJSON` accepts 10 000 levels.
129+
130+## Other things to check once the runner is available
131+
132+- [ ] Run the runner's expression test vectors (`ExpressionParserL0`,
133+ `ExpressionTests`, `ExpressionValuesL0`, `ExpressionTestUtil`) through a
134+ table adapter; list every disagreement here before deciding.
135+- [ ] Decide whether `EvalContext` needs a hook for the functions the runner
136+ implements natively with special semantics: `success()`, `failure()`,
137+ `always()`, `cancelled()` (take no arguments; read job status) and
138+ `hashFiles()` (variadic glob patterns, string result). The API supports
139+ them through `FuncInfo` + `Func` without changes; arity is declared by
140+ the caller.
141+- [ ] Context names the runner allows per location (`github`, `env`, `vars`,
142+ `job`, `jobs`, `steps`, `runner`, `secrets`, `strategy`, `matrix`,
143+ `needs`, `inputs`) and which functions each location may call
144+ (`success`... only in `if`/`jobs.*.if`...). This belongs to the template
145+ layer, not to package expr.
146+- [ ] `format()` argument limit is 255 in the JS (`MAX_ARGUMENTS`); the C#
147+ limit is `ExpressionConstants.MaxArguments` (check).
148+- [ ] Case-insensitive comparison and `contains`: if C# `OrdinalIgnoreCase`
149+ is the truth, replace `toUpperSpecial`/`jsToLower` by per-UTF-16-unit
150+ simple upper-casing and regenerate the oracle tests accordingly.
151+- [ ] Secret masking and the `${{ }}` splitting of a larger string are not
152+ part of this package (they belong to the template layer): `Parse`
153+ takes the text between the braces only.
@@ -0,0 +1,153 @@
1+# Porting notes for package expr
2+
3+The package is a port of `@actions/expressions` 0.3.61 (MIT, GitHub,
4+actions/languageservices). The JavaScript is the specification wherever this
5+package could be checked against it: `testdata/oracle.json.gz` holds what the
6+reference answers for 33 575 expressions (tokens with positions, error
7+messages, result kinds, string coercions, `toJSON` output), and
8+`TestOracle` replays it. `gen/oracle.mjs` regenerates it, `gen/golden.mjs` the
9+`testdata/tojson` goldens, and `gen/casing_dump.mjs` + `gen/casing.go` the
10+casing tables (Node 25 / V8 14.1, Unicode 16; Go 1.27 ships Unicode 15).
11+
12+The C# runner (actions/runner, `DistributedTask/Expressions2`) is the
13+implementation that runs on github.com. It was not available while this was
14+written. Nothing below has been checked against it.
15+
16+## Where the JavaScript is believed to differ from the C# runner
17+
18+Each item is implemented as the JavaScript does. Check the runner's tests
19+(`src/Test/L0/Expressions*`, `Sdk/Expressions2/...`) and decide for each one
20+whether to keep the JS behaviour or follow the runner.
21+
22+- [ ] **String escapes: only the first `''` is unescaped.** The lexer does
23+ `value.replace("''", "'")` with a string pattern, which replaces one
24+ occurrence: `'a''b''c'` is `a'b''c`. The C# lexer should unescape every
25+ pair (`StringBuilder`/`Replace`). Probable JS bug. (`lexer.go`,
26+ `consumeString`; tests: `TestEvalLiteralsAndOperators`.)
27+- [ ] **`case()` availability.** The JS gates it behind the experimental flag
28+ `allowCaseFunction`, but only in completion; the parser and the
29+ evaluator always accept it. Here it is always on. Confirm whether the
30+ hosted service accepts `case` and with which arity rules (the JS: at
31+ least 3 arguments, an odd number, predicates must be booleans).
32+- [ ] **`format()`**: the reference uses `parseInt` on the digits, so
33+ `{99999999999999999999}` is an Infinity-like index and reports "more
34+ arguments than were supplied". The `{0:x}` form silently ignores the
35+ specifier; C# may throw. The error texts are the JS ones. Check the C#
36+ `Format` node for the exact messages and for `{N}` with `N` > 255 or
37+ leading zeros.
38+- [ ] **`contains()` lower-cases (`toLowerCase`), `startsWith`/`endsWith`
39+ upper-case (`toUpperSpecial`).** The C# uses `OrdinalIgnoreCase`
40+ comparisons, which differ for `ß`, `İ`, `ı`, the Kelvin sign and final
41+ sigma. `contains('ß', 'ss')` is false in JS; `startsWith('ß', 'ss')` is
42+ true. Check what the runner does.
43+- [ ] **`==` on strings uses `toUpperSpecial`** (upper-case with the dotless
44+ `ı` left alone, full Unicode mapping so `ß == 'SS'`), and `<`/`>` compare
45+ the upper-cased UTF-16 code units. C# `OrdinalIgnoreCase` upper-cases
46+ per UTF-16 code unit with simple mappings only: `ß == 'SS'` should be
47+ false there.
48+- [ ] **Number to string (`CoerceString`)**: `(+x.toFixed(15)).toString()`.
49+ C# formats a `double` with `"G15"`-like rules. They agree for ordinary
50+ values; check very small and very large numbers (`1e-7` here is `1e-7`,
51+ `1e21` is `1e+21`), `-0`, and 16-17 significant digit values.
52+- [ ] **String to number**: `Number()` semantics (hex, octal, binary,
53+ `Infinity`, whitespace trimmed, empty is 0). C# `double.TryParse` with
54+ `NumberStyles` differs (no octal/binary; check `0x` and `Infinity`).
55+ The lexer applies the same conversion to number literals, so `0x10`,
56+ `0b11`, `1e3`, `.5`, `5.` and `+1` are all literals here.
57+- [ ] **Number literals after `-` / `+`**: `-1` is one literal token; `a -1`
58+ is therefore a parse error, as in the JS.
59+- [ ] **Object key order from `fromJSON`**: array-index keys first, ascending
60+ (JavaScript `Object.keys`), then insertion order. `Json.NET` keeps
61+ document order. The same ordering is applied by `toJSON` (the JS
62+ replacer builds a plain object). Check against the runner for
63+ `toJSON(fromJSON('{"b":1,"1":2}'))`.
64+- [ ] **Duplicate JSON keys**: last value wins at the first position (JS);
65+ keys that differ only by case collapse to the first.
66+- [ ] **`fromJSON` errors**: the JS message is
67+ `Error parsing JSON when evaluating fromJson` with the V8 `SyntaxError`
68+ as cause; here the cause is a Go error with similar but not identical
69+ wording (only the outer message is stable). `empty input` for blank
70+ input. C# messages differ; check.
71+- [ ] **`toJSON`**: `JSON.stringify(v, replacer, " ")`: two-space indent,
72+ `[]` and `{}` for empties, `NaN`/`Infinity` as `null`, `-0` as `0`,
73+ no escaping of `<`, `>`, `&`, U+2028/U+2029; lone surrogates as
74+ `\udXXX`. Json.NET differs on some of these (`Infinity`, `-0`, escapes).
75+- [ ] **Index access**: `a[b]` where `b` is a number uses `floor(b)` and
76+ rejects NaN, negatives and infinities; where `b` is a string it is a key
77+ or a decimal index (`'1'`, `' 1 '`, `'1.9'` all index 1 of an array).
78+ Booleans and null index as `1`/`0` on arrays and as `"true"`/`""` keys on
79+ dictionaries.
80+- [ ] **Filtered arrays** (`a.*`): a dictionary filtered by `*` yields its
81+ values; `.*.b` skips elements without `b`; `.*.*` flattens one level;
82+ arrays produced by `*` are fresh, so `a.* == a.*` is false.
83+- [ ] **Comparison**: `null` is only ordered against numbers/booleans (it
84+ becomes 0); arrays and dictionaries are never ordered and `==` is
85+ identity; `NaN` is unordered and unequal to itself; `true > false`.
86+- [ ] **`&&`/`||` return an operand**, not a boolean (`'' || 'x'` is `'x'`).
87+- [ ] **Limits**: max expression length 21000 (UTF-16 units; the JS throws a
88+ bare `Error("ErrorExceededMaxLength")`), max parse depth 50. The depth
89+ counting (including `index()` and the flat logical chains) is ported
90+ literally; C# `MaxDepth` is 50 with its own counting. Cross-check with
91+ deep-nesting tests. There is no node-count or evaluation-memory limit
92+ here; the C# runner has `MaxMemory` (1 MB) and a result-size limit.
93+- [ ] **Trailing comma in calls**: `contains('a', 'b',)` parses (the JS loop
94+ only requires a comma when the next token is not `)`). `f(,)` does not.
95+- [ ] **Whitespace**: only space, `\t`, `\r`, `\n` separate tokens. U+00A0 and
96+ other Unicode spaces end a word but are not skipped, so `1 \u00a0 2`
97+ reports an unexpected symbol. `\v`, `\f` likewise.
98+- [ ] **Empty expression**: the JS parser returns `undefined` for no tokens.
99+ `Parse` returns a nil Node and no error; `Evaluate(nil)` errors.
100+- [ ] **Unknown contexts at evaluation time** are null (the JS `Dictionary.get`
101+ returns `undefined` and `visitContextAccess` then crashes later); here
102+ declared-but-absent contexts are null, never a crash.
103+- [ ] **Error positions**: the JS EOF token's start column is negative after a
104+ trailing newline (stale `start`); ported as is
105+ (`TestEOFTokenPosition`).
106+- [ ] **Function names and context names are case-insensitive**; the JS
107+ lower-cases with `toLowerCase`, as does this port (`foldKey`). Extension
108+ functions override built-ins at evaluation (`functions?.get(...) ||
109+ wellKnownFunctions[...]`) but built-ins win at validation time (arity).
110+
111+## JavaScript behaviours deliberately NOT ported
112+
113+These are accidents of JavaScript objects used as maps; porting them would be
114+a bug.
115+
116+- `Dictionary.add` tests `key.toLowerCase() in this.indexMap` on a plain
117+ object, so keys that collide with `Object.prototype` properties
118+ (`constructor`, `__proto__`, `valueOf`, ...) are silently dropped or hidden.
119+ Here every key is stored.
120+- `wellKnownFunctions[name]` is a plain-object lookup, so `constructor(1)`,
121+ `toString(1)` and `hasOwnProperty(1)` pass validation or crash with a
122+ `TypeError` instead of "Unrecognized function". Here they are "Unrecognized
123+ function".
124+- A `TypeError` in the JS (`Cannot read properties of undefined`) when
125+ evaluating e.g. `github.true` with an empty context: the reference does not
126+ handle a missing root context. Here it is null.
127+- Recursion limits: JS recursion depth for `fromJSON` input is engine
128+ dependent (about 5 000 to 10 000 levels). `FromJSON` accepts 10 000 levels.
129+
130+## Other things to check once the runner is available
131+
132+- [ ] Run the runner's expression test vectors (`ExpressionParserL0`,
133+ `ExpressionTests`, `ExpressionValuesL0`, `ExpressionTestUtil`) through a
134+ table adapter; list every disagreement here before deciding.
135+- [ ] Decide whether `EvalContext` needs a hook for the functions the runner
136+ implements natively with special semantics: `success()`, `failure()`,
137+ `always()`, `cancelled()` (take no arguments; read job status) and
138+ `hashFiles()` (variadic glob patterns, string result). The API supports
139+ them through `FuncInfo` + `Func` without changes; arity is declared by
140+ the caller.
141+- [ ] Context names the runner allows per location (`github`, `env`, `vars`,
142+ `job`, `jobs`, `steps`, `runner`, `secrets`, `strategy`, `matrix`,
143+ `needs`, `inputs`) and which functions each location may call
144+ (`success`... only in `if`/`jobs.*.if`...). This belongs to the template
145+ layer, not to package expr.
146+- [ ] `format()` argument limit is 255 in the JS (`MAX_ARGUMENTS`); the C#
147+ limit is `ExpressionConstants.MaxArguments` (check).
148+- [ ] Case-insensitive comparison and `contains`: if C# `OrdinalIgnoreCase`
149+ is the truth, replace `toUpperSpecial`/`jsToLower` by per-UTF-16-unit
150+ simple upper-casing and regenerate the oracle tests accordingly.
151+- [ ] Secret masking and the `${{ }}` splitting of a larger string are not
152+ part of this package (they belong to the template layer): `Parse`
153+ 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