The library walks a C# LambdaExpression, emits a JavaScript string, and ships a
jsv_* runtime stub (stub.js) so the same C# lambda is the single source of
truth for both server-side and client-side form validation. Functions.cs
provides server-side equivalents; ValidationExpressionCompiler maps parameters
to reference()/value. Jint-based round-trip tests compile C# → run JS → compare.
This document captures the assessment and a prioritized plan.
- Sound core design: walk the expression, emit JS, ship a runtime stub.
- Jint round-trip tests are the right testing instinct.
- Good breadth: arithmetic, comparisons, conditionals, string ops, char
classification, regex, DateTime/TimeSpan, nullable, LINQ
Any/All/Count/First/Last, constant folding, and thereference()/valuemapping for form validation. netstandard2.0keeps reach wide.
Low-layer differential test suite added under Gehtsoft.ExpressionToJs.Tests/Complete/
(189 passing, 1 skipped). The §1 defects below are fixed and verified by those tests:
-
✅ Integer division truncates toward zero, and Int32 arithmetic wraps on overflow — both via a
| 0coercion applied to int-typed results (WrapInteger). -
✅
Math.Rounduses banker's rounding (jsv_round), including theMath.Round(x, digits)overload. -
✅
Powernow emitsjsv_power(stub function renamed fromjsv_powerof). -
✅ decimal/double equality tolerant between two numbers (
jsv_equalepsilon) so0.1 + 0.2 == 0.3. -
✅
==/!=are otherwise strict (===), matching C#'s typed comparison (no string/number coercion). -
✅ String and regex constants are escaped (
EscapeJsString,EscapeJsRegex). -
✅
get_Itemindexer bug (Arguments[1]→Arguments[0]). -
✅ Whitespace detection uses
\s(tabs/newlines/NBSP), matchingIsNullOrWhiteSpace. -
✅
jsv_string2intrejects trailing garbage (returnsNaN), matchingInt32.Parse. -
✅
jsv_yearssincesign convention now matchesFunctions.YearsSince. -
✅ Added support: null-coalescing
??(jsv_coalesce) andContains(jsv_contains, including array→spanMemoryExtensions.Contains). -
Corrected one old test (
CompilerTest.cs):intArr.Length / 2expected2.0(C# int division), not the previous2.5which had enshrined the bug. -
✅ DONE — Date/time mode (Local/UTC). A customer-set
DateTimeMode(defaultLocal; opt intoUtcvia the property or theExpressionCompiler(lambda, mode)ctor) governs every calendar-component construct consistently: constants (new Date(...)vsnew Date(Date.UTC(...))), member reads (getXvsgetUTCX), andMonthsSince/YearsSince(autcflag passed to the stub). Epoch-based ops (±TimeSpan, subtraction,AddDays,.Total*,DaysSince) are unchanged. The mode rides onIExpressionEmitContext.DateModeso the shared translators stay stateless. Contract: the host must bind JS-side dates in the same frame as the mode. The staticAddConstantshim stays Local-only. Tests inDateTimeModeTests. -
✅ DONE —
DateTime.Now/Today/UtcNoware dynamic. The fold check (ContainsNonConstant, formerlyReferencesFreeParameter) now treats these ambient-now accessors as non-constant, so they emit live JS instead of a frozen compile-time literal:Now/UtcNow→new Date(),Today→jsv_today(false|true)(mode-aware midnight). Chains likeDateTime.Now.AddDays(30)stay dynamic; component reads followDateMode. Tests inDateTimeModeTests. (DateTimeOffset.Now/UtcNowremain out of scope.) Follow-up still open: optional per-DateTimeKindconstant normalization.
Deferred (design-scope, not quick fixes):
- ⏸️ 64-bit
longprecision loss beyond 2^53 — unfixable without a bigint shim, and the tolerant double comparison can't catch it; no test added (would falsely pass).
The value proposition is that server and client agree. Several places silently disagree:
- Integer division diverges.
jsv_divide(a,b)emitsa / b. C#11 / 2is5; JS gives5.5. The compiler ignores operand types. Detect integer operand types and emitMath.trunc(a / b)(or(a/b)|0). Tests pass only because sampled values divide evenly. Math.Roundrounding mode diverges. C#Math.Roundis banker's rounding (ToEven):Math.Round(2.5) == 2; JSMath.round(2.5) == 3. Emit ajsv_roundreplicating ToEven, or honor theMidpointRoundingoverload.get_Itemindexer bug.ExpressionWalker.cs:562readsexpression.Arguments[1]for a single-argument indexer (Arguments.Count == 1) →IndexOutOfRangeException. Should beArguments[0].Powerreferences a non-existent stub function.ExpressionType.Power→"jsv_power"(line 64), but the stub definesjsv_powerof(stub.js:27). Power expressions produce a runtime ReferenceError.- No string/regex escaping in
AddConstant.$"'{constantValue}'"and/{pattern}/break on any',\, newline, or/in the value, and are a JS-injection vector if a constant ever derives from user/config data. Needs proper JS string escaping (and regex-source escaping). decimalconstants lose precision — emitted as a JS double. Real divergence for money validation; at minimum document it, ideally emit a fixed-point comparison helper.jsv_equaluses loose==.null == undefinedis true,"10" == 10is true. C#==does neither. Use===/!==(with deliberate null/undefined normalization).jsv_isemptyorwhitespaceonly matches spaces (/^ *$/) — tabs/newlines/Unicode whitespace pass as non-empty, unlike C#IsNullOrWhiteSpace. Use/^\s*$/.MonthsSince/YearsSince: server and client are structurally different algorithms. Tests only checkMath.Floor(Math.Abs(...))loosely, so off-by-one and sign disagreements are uncovered. Share one algorithm or pin with a differential test.- ✅ DONE — DateTime timezone handling. Resolved via the customer-set
DateTimeMode(Local/UTC) — see the Phase-1 status entry above for details. jsv_string2int=parseInt(s)(no radix) is lenient:parseInt("12abc")is12, C#Int32.Parsethrows. Behavior should match the chosen contract.
Given the goal, the single highest-value addition is property-based differential testing:
generate random inputs, run the C# lambda and the Jint-evaluated JS, and assert equality across a
large sample. This is the only thing that reliably catches the class of divergences above (rounding,
integer division, date math, null semantics) and keeps stub.js in sync with Functions.cs — which
today must be hand-synchronized and has already drifted. Pair it with golden-file tests on the emitted
JS string for stability.
-
✅ DONE —
AddCallgod method refactored. The ~300-line method is now a thin dispatch loop over an ordered translator pipeline: a data-drivenTableMethodTranslatorfor the trivial 1:1 maps plus focusedIMethodCallTranslatorclasses for the conditional cases (Regex, IndexOf, StartsWith, Contains, LINQ, indexer, ToString) — seeMethodTranslators.cs. Built-ins are a fixed, immutable, correctly-ordered list; consumers extend via theIJsMethodRegistry(compiler.Methods) which adds leaf-level translations consulted before built-ins (so they can shadow a method) but cannot reorder or remove built-ins, preserving precedence invariants. Behavior unchanged (full suite green; complex precedence tests added). The symmetricAddMemberAccess/property side (IMemberTranslator) is the natural follow-up. -
✅ DONE —
GetExpressionValueno longer compiles every node. It now first runs a cheapFreeParameterFinder(anExpressionVisitor) to detect a reference to a free parameter (one not bound by a lambda inside the subtree); only closed subtrees are compiled+invoked, still inside a try/catch so a constant that throws at eval falls back to structural translation exactly as before. This removes the failed JIT compile + swallowed exception that previously fired at every parameter-referencing node. Behavior preserved (full suite green;ConstantFoldingTestslock the free-vs-lambda-bound distinction). Possible later optimization: memoize the finder to avoid O(n²) on pathologically large trees (negligible for validation-sized predicates). -
✅ DONE — Extensibility model. Three flat, symmetric registries on the compiler, each backed by the same interfaces the built-ins use (dogfooded):
- Methods —
compiler.Methods(IJsMethodRegistry):AddTranslator(IMethodCallTranslator),MapMethod(Type,name),MapMethod(MethodInfo). - Constants —
compiler.Constants(IJsConstantRegistry):AddTranslator(IConstantTranslator),MapConstant<T>(Func<T,string>). Unblocks Guid/enum/value-struct constants. Thepublic static AddConstantstays as a back-compat shim sharing the built-in path; emission now routes through an instance pipeline so custom + built-in run uniformly. - Members —
compiler.Members(IJsMemberRegistry):AddTranslator(IMemberTranslator),MapMember(Type,name,template).AddMemberAccessis now a dispatch loop over a data-drivenTableMemberTranslator(DateTime., TimeSpan., Nullable, Length/Count) plus the terminal parameter-access fallback. - Parameters —
compiler.Parameters(IJsParameterRegistry), added 2026-06-19:MapReference(Func<Type,bool>)turns on the built-in form-validationreference()rendering (bare param →reference(), member chain →reference('Path'), array index →jsv_index);Map(Func<Type,bool>, Func<ParameterExpression,string> parameter, Func<Expression,ParameterExpression,string> access)emits any custom shape (e.g.value.Property, orreference('Type','path')). Off by default (unchanged verbatim emission); matches by type predicate but applies only to the rule's root parameters (nested LINQ-lambda params keep their names). Public helperExpressionCompiler.ParameterAccessPath(Expression)turns a member chain into a dotted path for reuse in customaccessfuncs. This makes the form-validationreference()/valuebinding a built-in feature — consumers no longer subclassExpressionCompilerfor it. Tests:ParameterRegistryTests(17). SeeREFERENCE_REFACTOR.mdfor migrating Gehtsoft.EF.Toolbox'sValidationExpressionCompileronto it.
Matching model (decided): translators are keyed by type + name (+ arity/shape); built-ins are disjoint, so iteration order is not load-bearing and two matches at one tier is a caller logic error. The single deliberate ordering is the member parameter-access fallback, which is terminal (a typed member like
DateTime.Yearwins over it). User registrations run before built-ins purely as a deliberate-override escape hatch. New tests:MethodRegistryTests,ValueRegistryTests.Boundary note (decided): the library's contract is just (1) C# expression → JS text and (2) the canonical stub source via
ExpressionToJsStubAccessor.GetJsIncludesAsString(). How the consumer loads, bundles, or minifies JS — and any companion helpers for their custom mappings — is entirely theirs. So there is deliberately no companion-JS registration API. - Methods —
-
Stub tree-shaking — WON'T DO (decided). The stub is loaded once (long-lived JS engine, or a site-wide script), so "emit only the helpers this expression used" buys nothing and adds real complexity (per-expression usage tracking, dedup across expressions on a page). Not worth it.
-
No "give me a complete function" API. Consumers must concatenate stub + expression themselves. A
CompileToFunction(name, paramNames)returning a readyfunction(...){ return ...; }(optionally bundled with the needed stub) would be much friendlier.
Rough priority for form-validation use cases:
- Null-coalescing
??(ExpressionType.Coalesce) — unsupported today, very common. - String concatenation — C#
"a" + bon strings compiles to astring.Concatcall, notAdd; not handled today. - ✅ DONE (partial) — more string methods:
EndsWith(+StringComparison),Replace,PadLeft/PadRight(with optional pad char),TrimStart/TrimEnd,ToUpperInvariant/ToLowerInvariant,LastIndexOf, plus char-literal args (Replace('a','b'),Contains('a'),PadLeft(5,'0')) via new char-constant support. Deliberately not done:string.FormatandSplit(locale/format-correctness on the JS side is a nightmare — out of scope by decision). - ✅ DONE (most) — more LINQ:
Where/Select(return JS arrays, so they chain),Sum/Min/Max(with or without selector),Distinct, on top of the existingAny/All/Count/First/Last.Containswas already done. Tests inLinqTests(incl. chainedWhere→Select→Sum). Still TODO:Average, andFirstOrDefault/LastOrDefaultwithout a predicate. - ✅ DONE — Enums generally. Enum constants now emit their underlying numeric value (via
TryEmitBuiltinConstant→value is Enum), so any enum works, not justDayOfWeek— consistent with theConvert(enum→int)path comparisons already use. Flags enums emit the combined value. Per-type name emission is available viacompiler.Constants.MapConstant<TEnum>(e => "'" + e + "'")(for bare constants). Contract: client-side enum fields must carry the numeric underlying value. Tests inEnumTests. Global name-mode (withConvertinterception so comparisons stay consistent) remains an opt-in follow-up — see §3 enum discussion. - ✅ DONE —
ExclusiveOr(^): logical XOR on bool (jsv_boolxor, returns a real boolean) and bitwise XOR on integers (jsv_xor,| 0-wrapped). Still TODO: bit shifts,int.TryParse/double.TryParsepatterns. DateTime.Today,DateTime.UtcNow,DateTimeOffset,TimeSpan.FromX,DateTime.Parse, andAddMonths/AddYears(only Add days/hours/minutes/seconds exist).- Clear, actionable error messages for unsupported nodes (include the offending member and a hint).
- Packaging / CI / nullable — still open: nupkg version is stale, no visible CI, nullable reference types off. Worth a packaging refresh if this stays in service.
- ✅ DONE — README for the library (intent, examples, project layout).
- ✅ DONE — XML doc comments on the public API — see the Documentation section below.
- Tests are good in spirit but are giant multi-assert
[Fact]s — convert to[Theory]/InlineDatafor failure isolation, and add negative tests (unsupported expression → expected exception/message).
Goal: a docgen-built reference + guides, with the public API documented from XML comments so the
reference stays in sync with the code. Project lives in help/ (docgen; Gehtsoft.Build.DocGen).
Done:
-
✅ Public API XML doc comments, written use-first (why/when/how, not behavior; parameters framed by how their values change the outcome). Covers
ExpressionCompiler(class, ctors,JavaScriptExpression,DateMode,Methods/Constants/Members, the statics,Equals),Functions.*,ExpressionToJsStubAccessor,DateTimeMode, and the extension contracts (IMethodCallTranslator/IConstantTranslator/IMemberTranslator+ the registry interfaces). -
✅ Public surface minimized. All concrete translators (focused built-ins +
Table*/Delegate*) are nowinternal; only the entry points and extension interfaces are public. Test access viaInternalsVisibleTo. Confirmed: internals no longer appear in generated docs. -
✅ docgen wired up and building.
help/produces the API reference from the built DLL (Asm2Xml→cs2ds→src/raw/*.ds) merged with hand-written.ds;MakeDocis green. -
✅
index.dslanding page written — the why (one rule, two runtimes, no drift) with a short API-at-a-glance; how deferred to articles. -
✅ docgen authoring conventions learned & captured in
DOCGEN_SKILL_BUGS.md(3 issues, with fixes + automatable lints): (1) the whole<summary>becomes the one-line@briefand<remarks>is dropped — so writeone-line brief+ blank line + detail paragraphs, no<remarks>; (2) use[c]/[i]/[b]BBCode, not XML<c>/<i>/<b>(which cs2ds mangles); (3) a line starting with-/*becomes a stray list bullet — never begin a line (or a wrapped///line) with one. -
✅ DONE — Namespace overview in
src/ns/Gehtsoft.ExpressionToJs.ds(hand-written@group): entry points + extension contracts, links to the API classes/interfaces. Merges with the empty auto-generated group (hand-written loads first, wins). Guides navigation lives inindex.ds, not here. -
🔄 How-to articles — in progress, tracked page-by-page in
DOCUMENTATION_PLAN.md(the live doc plan; read it first next session). Structure: a singlesrc/articles.dswith seven@articles inmain(noguidesgroup),@sortarticles=no, wired intoproject.xmlas one<dg:file>. The user validates each page before the next.- Done:
index.ds(API-at-a-glance 30/70 table; guides auto-listed), namespace overview, Validating on all tiers from one source of truth (walkthrough; two-runtimes mermaid; ship via Razor inline + REST endpoint; rule-as-function + JS call), Validating against a model object (uses the newParametersregistry —MapReference(_ => true)in an app-level factory; host side; the two-accessor / multi-form / object-access customizations). - Remaining: Wiring into client frameworks (stub — ASP.NET/jQuery UI/Angular/React/Vue); Supported expression features (consistent table layout #8b, demote extension warning #8c); Dates and time zones (light layout pass); Extending (full redesign #9: per-scenario method/property/constant with JS side + extension-contracts mermaid class diagram); Unit-testing (boolean-validation focus #7). Plus a final extension-contracts class diagram.
- Docgen authoring gotchas captured in
DOCGEN_SKILL_BUGS.md(Bugs 1–7): one-line@brief; BBCode not XML; no line-leading-/*;&→&but raw</>;!-prefix for indented@example;@show=yesto expand; mermaid via@highlight=diagram+@show=yes;@width=100%(with%) +@col @width30/70;[clink]to class keys; no@see→ use a "See also"@list. Standing rule: every emitted-JS example must be backed by a test.
- Done:
Still to do (after the articles):
- Publish/host the generated docs; fold into the packaging refresh above.
- (Optional) Deep-dive articles — integration internals and the translator-pipeline model — were folded into the walkthrough/extending guides rather than written separately; revisit if a dedicated internals section is wanted.
This library lives under the Gehtsoft.Tools tree, which is being frozen on net8.0 and superseded by
Gehtsoft.Tools2. Before investing heavily, decide whether ExpressionToJs is migrating into Tools2 —
if so, the architecture rework (dispatch registry, extensibility API, differential tests) is best done
as part of that move rather than twice.
- ✅ Fix the correctness bugs (§1).
- ✅ Stand up the differential test harness (§2).
- ✅ Refactor
AddCallinto a registry (§3). - Expand features (§4) — most done; §4 stragglers remain (
Average, predicate-lessFirstOrDefault/LastOrDefault, bit shifts,TryParse, string concat,AddMonths/AddYears). - Documentation (§6) — in progress: API XML comments + docgen +
index.dsdone; namespace overview, how-to and deep-dive articles still to write.