From 231b0d434d09af5d99616d5f2b9cff2df60a7f40 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:32:14 +0100 Subject: [PATCH 01/18] Port core library to .NET Standard 2.0 --- Evaluant.Calculator/NCalc.csproj | 134 +++++------------------------ Evaluant.Calculator/NCalcParser.cs | 126 +++++++++++++++------------ Grammar/NCalc.g | 2 +- 3 files changed, 94 insertions(+), 168 deletions(-) diff --git a/Evaluant.Calculator/NCalc.csproj b/Evaluant.Calculator/NCalc.csproj index ae67ff7..714a9e3 100644 --- a/Evaluant.Calculator/NCalc.csproj +++ b/Evaluant.Calculator/NCalc.csproj @@ -1,113 +1,21 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {5F014003-50D8-49E0-8AFE-91D38DCCC97C} - Library - Properties - NCalc - NCalc - - - 3.5 - - - v3.5 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - true - AllRules.ruleset - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - AllRules.ruleset - - - - False - ..\Binaries\Antlr3.Runtime.dll - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - true - - - False - Windows Installer 3.1 - true - - - - - \ No newline at end of file + + + net35;netstandard2.0 + NCalc + NCalc + false + 1.3.8 + NCalc + false + Sebastien Ros + NCalc is a mathematical expressions evaluator in .NET. + + + + + + + + + + diff --git a/Evaluant.Calculator/NCalcParser.cs b/Evaluant.Calculator/NCalcParser.cs index 49dd32b..ed75d4d 100644 --- a/Evaluant.Calculator/NCalcParser.cs +++ b/Evaluant.Calculator/NCalcParser.cs @@ -185,11 +185,12 @@ public override void DisplayRecognitionError(String[] tokenNames, RecognitionExc partial void LeaveRule(string ruleName, int ruleIndex); #region Rules - public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_ncalcExpression(); @@ -262,11 +263,12 @@ public NCalcParser.ncalcExpression_return ncalcExpression() } // $ANTLR end "ncalcExpression" - public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_logicalExpression(); @@ -390,11 +392,12 @@ private NCalcParser.logicalExpression_return logicalExpression() } // $ANTLR end "logicalExpression" - public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_conditionalExpression(); @@ -530,11 +533,12 @@ private NCalcParser.conditionalExpression_return conditionalExpression() } // $ANTLR end "conditionalExpression" - public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_booleanAndExpression(); @@ -670,11 +674,12 @@ private NCalcParser.booleanAndExpression_return booleanAndExpression() } // $ANTLR end "booleanAndExpression" - public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_bitwiseOrExpression(); @@ -800,11 +805,12 @@ private NCalcParser.bitwiseOrExpression_return bitwiseOrExpression() } // $ANTLR end "bitwiseOrExpression" - public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_bitwiseXOrExpression(); @@ -930,11 +936,12 @@ private NCalcParser.bitwiseXOrExpression_return bitwiseXOrExpression() } // $ANTLR end "bitwiseXOrExpression" - public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_bitwiseAndExpression(); @@ -1060,11 +1067,12 @@ private NCalcParser.bitwiseAndExpression_return bitwiseAndExpression() } // $ANTLR end "bitwiseAndExpression" - public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_equalityExpression(); @@ -1262,11 +1270,12 @@ private NCalcParser.equalityExpression_return equalityExpression() } // $ANTLR end "equalityExpression" - public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_relationalExpression(); @@ -1490,11 +1499,12 @@ private NCalcParser.relationalExpression_return relationalExpression() } // $ANTLR end "relationalExpression" - public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_shiftExpression(); @@ -1672,11 +1682,12 @@ private NCalcParser.shiftExpression_return shiftExpression() } // $ANTLR end "shiftExpression" - public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_additiveExpression(); @@ -1854,11 +1865,12 @@ private NCalcParser.additiveExpression_return additiveExpression() } // $ANTLR end "additiveExpression" - public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_multiplicativeExpression(); @@ -2061,11 +2073,12 @@ private NCalcParser.multiplicativeExpression_return multiplicativeExpression() } // $ANTLR end "multiplicativeExpression" - public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_unaryExpression(); @@ -2275,11 +2288,12 @@ private NCalcParser.unaryExpression_return unaryExpression() } // $ANTLR end "unaryExpression" - public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_primaryExpression(); @@ -2472,11 +2486,12 @@ private NCalcParser.primaryExpression_return primaryExpression() } // $ANTLR end "primaryExpression" - public class value_return : ParserRuleReturnScope, IAstRuleReturnScope + public class value_return : ParserRuleReturnScope, IAstRuleReturnScope { public ValueExpression value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_value(); @@ -2684,11 +2699,12 @@ private NCalcParser.value_return value() } // $ANTLR end "value" - public class identifier_return : ParserRuleReturnScope, IAstRuleReturnScope + public class identifier_return : ParserRuleReturnScope, IAstRuleReturnScope { public Identifier value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_identifier(); @@ -2800,11 +2816,12 @@ private NCalcParser.identifier_return identifier() } // $ANTLR end "identifier" - public class expressionList_return : ParserRuleReturnScope, IAstRuleReturnScope + public class expressionList_return : ParserRuleReturnScope, IAstRuleReturnScope { public List value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_expressionList(); @@ -2930,11 +2947,12 @@ private NCalcParser.expressionList_return expressionList() } // $ANTLR end "expressionList" - public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope + public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope { public List value; - private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + private CommonTree _tree; + public CommonTree Tree { get { return _tree; } set { _tree = value; } } + object IAstRuleReturnScope.Tree { get { return Tree; } } } partial void Enter_arguments(); diff --git a/Grammar/NCalc.g b/Grammar/NCalc.g index 3dc50b7..f81436a 100644 --- a/Grammar/NCalc.g +++ b/Grammar/NCalc.g @@ -183,7 +183,7 @@ multiplicativeExpression returns [LogicalExpression value] @init { BinaryExpressionType type = BinaryExpressionType.Unknown; } - : left=unaryExpression { $value = $left.value); } ( + : left=unaryExpression { $value = $left.value; } ( ( '*' { type = BinaryExpressionType.Times; } | '/' { type = BinaryExpressionType.Div; } | '%' { type = BinaryExpressionType.Modulo; } ) From eecfd9462f6ac40f3a1ed41fb46036f57a141717 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:31:48 +0100 Subject: [PATCH 02/18] Modernize existing test project --- Evaluant.Calculator.Tests/AssemblyInfo.cs | 58 --------- Evaluant.Calculator.Tests/Fixtures.cs | 13 +- Evaluant.Calculator.Tests/MSTestSettings.cs | 4 + Evaluant.Calculator.Tests/NCalc.Tests.csproj | 122 +++--------------- .../Properties/AssemblyInfo.cs | 35 ----- 5 files changed, 30 insertions(+), 202 deletions(-) delete mode 100644 Evaluant.Calculator.Tests/AssemblyInfo.cs create mode 100644 Evaluant.Calculator.Tests/MSTestSettings.cs delete mode 100644 Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs diff --git a/Evaluant.Calculator.Tests/AssemblyInfo.cs b/Evaluant.Calculator.Tests/AssemblyInfo.cs deleted file mode 100644 index 177a4f0..0000000 --- a/Evaluant.Calculator.Tests/AssemblyInfo.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; - -// -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -// -[assembly: AssemblyTitle("")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("")] -[assembly: AssemblyCopyright("")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: - -[assembly: AssemblyVersion("1.0.*")] - -// -// In order to sign your assembly you must specify a key to use. Refer to the -// Microsoft .NET Framework documentation for more information on assembly signing. -// -// Use the attributes below to control which key is used for signing. -// -// Notes: -// (*) If no key is specified, the assembly is not signed. -// (*) KeyName refers to a key that has been installed in the Crypto Service -// Provider (CSP) on your machine. KeyFile refers to a file which contains -// a key. -// (*) If the KeyFile and the KeyName values are both specified, the -// following processing occurs: -// (1) If the KeyName can be found in the CSP, that key is used. -// (2) If the KeyName does not exist and the KeyFile does exist, the key -// in the KeyFile is installed into the CSP and used. -// (*) In order to create a KeyFile, you can use the sn.exe (Strong Name) utility. -// When specifying the KeyFile, the location of the KeyFile should be -// relative to the project output directory which is -// %Project Directory%\obj\. For example, if your KeyFile is -// located in the project directory, you would specify the AssemblyKeyFile -// attribute as [assembly: AssemblyKeyFile("..\\..\\mykey.snk")] -// (*) Delay Signing is an advanced option - see the Microsoft .NET Framework -// documentation for more information on this. -// -[assembly: AssemblyDelaySign(false)] -[assembly: AssemblyKeyFile("")] -[assembly: AssemblyKeyName("")] diff --git a/Evaluant.Calculator.Tests/Fixtures.cs b/Evaluant.Calculator.Tests/Fixtures.cs index 774a8b4..5d29a92 100644 --- a/Evaluant.Calculator.Tests/Fixtures.cs +++ b/Evaluant.Calculator.Tests/Fixtures.cs @@ -576,13 +576,16 @@ public void ShouldCompareLongValues() Assert.AreEqual(false, new Expression("(0=1500000)||(((0+2200000000)-1500000)<0)").Evaluate()); } - [TestMethod, ExpectedException(typeof(InvalidOperationException))] + [TestMethod] public void ShouldDisplayErrorIfUncompatibleTypes() { - var e = new Expression("(a > b) + 10"); - e.Parameters["a"] = 1; - e.Parameters["b"] = 2; - e.Evaluate(); + Assert.Throws(() => + { + var e = new Expression("(a > b) + 10"); + e.Parameters["a"] = 1; + e.Parameters["b"] = 2; + e.Evaluate(); + }); } [TestMethod] diff --git a/Evaluant.Calculator.Tests/MSTestSettings.cs b/Evaluant.Calculator.Tests/MSTestSettings.cs new file mode 100644 index 0000000..6c59e26 --- /dev/null +++ b/Evaluant.Calculator.Tests/MSTestSettings.cs @@ -0,0 +1,4 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +// Keeps the legacy behavioral suite serial after the MSTest migration. +[assembly: DoNotParallelize] diff --git a/Evaluant.Calculator.Tests/NCalc.Tests.csproj b/Evaluant.Calculator.Tests/NCalc.Tests.csproj index 0dccdbd..d9c73fa 100644 --- a/Evaluant.Calculator.Tests/NCalc.Tests.csproj +++ b/Evaluant.Calculator.Tests/NCalc.Tests.csproj @@ -1,104 +1,18 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4} - Library - Properties - NCalc.Tests - NCalc.Tests - v3.5 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - - - 3.5 - - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - AllRules.ruleset - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - AllRules.ruleset - - - - False - ..\Binaries\Antlr3.Runtime.dll - - - - - - - - - - - {5F014003-50D8-49E0-8AFE-91D38DCCC97C} - NCalc - - - - - False - Microsoft .NET Framework 4 %28x86 and x64%29 - true - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - false - - - False - Windows Installer 3.1 - true - - - - - \ No newline at end of file + + + net10.0 + NCalc.Tests + NCalc.Tests + latest + disable + + + + + + + + + + + diff --git a/Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs b/Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 9bf4bd1..0000000 --- a/Evaluant.Calculator.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Evaluant.Calculator.Tes.20")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Evaluant.Calculator.Tes.20")] -[assembly: AssemblyCopyright("Copyright © 2007")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("cd623ffb-0b37-4cc1-9df6-8d91160d8021")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] From 9e08ae6720a4a5e52ace5c12c6c8874d2a2470ac Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:32:40 +0100 Subject: [PATCH 03/18] Expand expression behavior coverage --- .../BuiltInFunctionTests.cs | 130 +++++++++++++ .../CustomFunctionAndParameterTests.cs | 137 ++++++++++++++ .../EvaluationOptionTests.cs | 119 ++++++++++++ .../ExpressionPublicApiTests.cs | 176 ++++++++++++++++++ .../ParserSyntaxTests.cs | 120 ++++++++++++ .../SerializationTests.cs | 103 ++++++++++ Evaluant.Calculator.Tests/TestSupport.cs | 40 ++++ 7 files changed, 825 insertions(+) create mode 100644 Evaluant.Calculator.Tests/BuiltInFunctionTests.cs create mode 100644 Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs create mode 100644 Evaluant.Calculator.Tests/EvaluationOptionTests.cs create mode 100644 Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs create mode 100644 Evaluant.Calculator.Tests/ParserSyntaxTests.cs create mode 100644 Evaluant.Calculator.Tests/SerializationTests.cs create mode 100644 Evaluant.Calculator.Tests/TestSupport.cs diff --git a/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs b/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs new file mode 100644 index 0000000..2984772 --- /dev/null +++ b/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs @@ -0,0 +1,130 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes built-in functions, including current arity validation. + /// + [TestClass] + public class BuiltInFunctionTests + { + [TestMethod] + public void MathFunctions_WithRepresentativeInputs_ReturnExpectedResults() + { + object[,] cases = + { + { "Abs(-12)", 12M }, + { "Ceiling(1.1)", 2D }, + { "Floor(1.9)", 1D }, + { "Max(10, 20)", 20 }, + { "Min(10, 20)", 10 }, + { "Max(1.5, 2.5)", 2.5D }, + { "Min(1.5, 2.5)", 1.5D }, + { "Pow(2, 8)", 256D }, + { "Round(3.456, 2)", 3.46D }, + { "Sign(-12)", -1 }, + { "Truncate(3.9)", 3D } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void TrigonometricFunctions_WithRepresentativeInputs_ReturnExpectedResults() + { + object[,] cases = + { + { "Acos(1)", 0D }, + { "Asin(0)", 0D }, + { "Atan(0)", 0D }, + { "Cos(0)", 1D }, + { "Exp(0)", 1D }, + { "IEEERemainder(3, 2)", -1D }, + { "Log(1, 10)", 0D }, + { "Log10(1)", 0D }, + { "Sin(0)", 0D }, + { "Sqrt(9)", 3D }, + { "Tan(0)", 0D } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void BuiltInFunctions_WithWrongArity_ThrowArgumentException() + { + string[,] cases = + { + { "Abs()", "Abs() takes exactly 1 argument" }, + { "Acos()", "Acos() takes exactly 1 argument" }, + { "Asin()", "Asin() takes exactly 1 argument" }, + { "Atan()", "Atan() takes exactly 1 argument" }, + { "Ceiling()", "Ceiling() takes exactly 1 argument" }, + { "Cos()", "Cos() takes exactly 1 argument" }, + { "Exp()", "Exp() takes exactly 1 argument" }, + { "Floor()", "Floor() takes exactly 1 argument" }, + { "IEEERemainder(1)", "IEEERemainder() takes exactly 2 arguments" }, + { "Log(1)", "Log() takes exactly 2 arguments" }, + { "Log10()", "Log10() takes exactly 1 argument" }, + { "Pow(1)", "Pow() takes exactly 2 arguments" }, + { "Round(1)", "Round() takes exactly 2 arguments" }, + { "Sign()", "Sign() takes exactly 1 argument" }, + { "Sin()", "Sin() takes exactly 1 argument" }, + { "Sqrt()", "Sqrt() takes exactly 1 argument" }, + { "Tan()", "Tan() takes exactly 1 argument" }, + { "Truncate()", "Truncate() takes exactly 1 argument" }, + { "Max(1)", "Max() takes exactly 2 arguments" }, + { "Min(1)", "Min() takes exactly 2 arguments" }, + { "if(true, 1)", "if() takes exactly 3 arguments" }, + { "in(1)", "in() takes at least 2 arguments" } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression(cases[i, 0]).Evaluate(); }); + + TestSupport.StringContains(exception.Message, cases[i, 1]); + } + } + + [TestMethod] + public void BuiltInFunctions_WithWrongCaseWhenCaseSensitive_ThrowHelpfulArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression("max(1, 2)").Evaluate(); }); + + TestSupport.StringContains(exception.Message, "Function not found max. Try Max instead."); + } + + [TestMethod] + public void UnknownFunction_WithNoCustomHandler_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression("DoesNotExist(1)").Evaluate(); }); + + TestSupport.StringContains(exception.Message, "Function not found"); + Assert.AreEqual("DoesNotExist", exception.ParamName); + } + + [TestMethod] + public void IfFunction_WithUnusedBranchContainingError_DoesNotEvaluateUnusedBranch() + { + Assert.AreEqual(42, TestSupport.Evaluate("if(true, 42, 1 / 0)")); + Assert.AreEqual(42, TestSupport.Evaluate("if(false, 1 / 0, 42)")); + } + + [TestMethod] + public void InFunction_WithMatchBeforeInvalidExpression_DoesNotEvaluateRemainingArguments() + { + Assert.AreEqual(true, TestSupport.Evaluate("in(4, 1, 2, 4, 1 / 0)")); + } + } +} diff --git a/Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs b/Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs new file mode 100644 index 0000000..31f3b1f --- /dev/null +++ b/Evaluant.Calculator.Tests/CustomFunctionAndParameterTests.cs @@ -0,0 +1,137 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes custom callback behavior and event propagation. + /// + [TestClass] + public class CustomFunctionAndParameterTests + { + [TestMethod] + public void FunctionArgsEvaluateParameters_WithCustomFunction_EvaluatesAllArgumentsInOrder() + { + var expression = new Expression("SumAll([a], 2, [b])"); + expression.Parameters["a"] = 3; + expression.Parameters["b"] = 4; + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + if (name == "SumAll") + { + object[] values = args.EvaluateParameters(); + args.Result = (int)values[0] + (int)values[1] + (int)values[2]; + } + }; + + Assert.AreEqual(9, expression.Evaluate()); + } + + [TestMethod] + public void CustomFunction_WithNullResult_SetsHasResultAndReturnsNull() + { + var expression = new Expression("NullFunction(1)"); + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + Assert.IsFalse(args.HasResult); + args.Result = null; + Assert.IsTrue(args.HasResult); + }; + + Assert.IsNull(expression.Evaluate()); + } + + [TestMethod] + public void CustomParameter_WithNullResult_SetsHasResultAndReturnsNull() + { + var expression = new Expression("[MaybeNull]"); + + expression.EvaluateParameter += delegate(string name, ParameterArgs args) + { + Assert.IsFalse(args.HasResult); + args.Result = null; + Assert.IsTrue(args.HasResult); + }; + + Assert.IsNull(expression.Evaluate()); + } + + [TestMethod] + public void CustomFunction_WithUnusedArgument_DoesNotEvaluateUnusedArgument() + { + var expression = new Expression("First(10, 1 / 0)"); + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + args.Result = args.Parameters[0].Evaluate(); + }; + + Assert.AreEqual(10, expression.Evaluate()); + } + + [TestMethod] + public void NestedCustomFunctions_WithParameters_PropagateCallbacksToChildExpressions() + { + var expression = new Expression("Double(Add([x], 3))"); + expression.Parameters["x"] = 4; + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + if (name == "Add") + { + object[] values = args.EvaluateParameters(); + args.Result = Convert.ToInt32(values[0]) + Convert.ToInt32(values[1]); + } + + if (name == "Double") + { + args.Result = Convert.ToInt32(args.Parameters[0].Evaluate()) * 2; + } + }; + + Assert.AreEqual(14, expression.Evaluate()); + } + + [TestMethod] + public void CustomParameterCallback_WithNoDictionaryValue_ProvidesParameterValue() + { + var expression = new Expression("[Dynamic] + 2"); + + expression.EvaluateParameter += delegate(string name, ParameterArgs args) + { + if (name == "Dynamic") + { + args.Result = 40; + } + }; + + Assert.AreEqual(42, expression.Evaluate()); + } + + [TestMethod] + public void CustomParameterCallback_WithNoResult_ThrowsArgumentException() + { + var expression = new Expression("[Missing]"); + + ArgumentException exception = TestSupport.Throws( + delegate { expression.Evaluate(); }); + + TestSupport.StringContains(exception.Message, "Parameter was not defined"); + Assert.AreEqual("Missing", exception.ParamName); + } + + [TestMethod] + public void ParameterExpression_WithOuterParameters_UsesOuterParameterValues() + { + var expression = new Expression("[total] + [tax]"); + expression.Parameters["total"] = new Expression("[price] * [quantity]"); + expression.Parameters["price"] = 10; + expression.Parameters["quantity"] = 3; + expression.Parameters["tax"] = 2; + + Assert.AreEqual(32, expression.Evaluate()); + } + } +} diff --git a/Evaluant.Calculator.Tests/EvaluationOptionTests.cs b/Evaluant.Calculator.Tests/EvaluationOptionTests.cs new file mode 100644 index 0000000..3e4a8a1 --- /dev/null +++ b/Evaluant.Calculator.Tests/EvaluationOptionTests.cs @@ -0,0 +1,119 @@ +using System.Collections; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes EvaluateOptions behavior, especially iteration edge cases. + /// + [TestClass] + public class EvaluationOptionTests + { + [TestMethod] + public void IgnoreCase_WithDifferentFunctionCasing_AllowsBuiltInFunctionLookup() + { + Assert.AreEqual(1M, new Expression("aBs(-1)", EvaluateOptions.IgnoreCase).Evaluate()); + } + + [TestMethod] + public void IgnoreCase_WithCustomFunction_LowersCallbackFunctionName() + { + var expression = new Expression("MiXeD(1)", EvaluateOptions.IgnoreCase); + string callbackName = null; + + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + callbackName = name; + args.Result = args.Parameters[0].Evaluate(); + }; + + Assert.AreEqual(1, expression.Evaluate()); + Assert.AreEqual("mixed", callbackName); + } + + [TestMethod] + public void RoundAwayFromZero_WithMidpointValue_ChangesRoundBehavior() + { + Assert.AreEqual(22D, new Expression("Round(22.5, 0)").Evaluate()); + Assert.AreEqual(23D, new Expression("Round(22.5, 0)", EvaluateOptions.RoundAwayFromZero).Evaluate()); + } + + [TestMethod] + public void IterateParameters_WithMatchingEnumerableParameters_ReturnsResultPerIndex() + { + var expression = new Expression("[x] + [y]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2, 3 }; + expression.Parameters["y"] = new[] { 10, 20, 30 }; + + var result = (IList)expression.Evaluate(); + + Assert.AreEqual(3, result.Count); + Assert.AreEqual(11, result[0]); + Assert.AreEqual(22, result[1]); + Assert.AreEqual(33, result[2]); + } + + [TestMethod] + public void IterateParameters_WithMismatchedEnumerableLengths_ThrowsEvaluationException() + { + var expression = new Expression("[x] + [y]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2 }; + expression.Parameters["y"] = new[] { 10, 20, 30 }; + + EvaluationException exception = TestSupport.Throws( + delegate { expression.Evaluate(); }); + + TestSupport.StringContains(exception.Message, "IEnumerable parameters must have the same number of items"); + } + + [TestMethod] + public void IterateParameters_WithMixedScalarAndEnumerableParameters_ReusesScalarForEveryIndex() + { + var expression = new Expression("[x] + [offset]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2, 3 }; + expression.Parameters["offset"] = 10; + + var result = (IList)expression.Evaluate(); + + Assert.AreEqual(3, result.Count); + Assert.AreEqual(11, result[0]); + Assert.AreEqual(12, result[1]); + Assert.AreEqual(13, result[2]); + } + + [TestMethod] + public void IterateParameters_WithEmptyEnumerable_ReturnsEmptyResultList() + { + var expression = new Expression("[x] + 1", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new int[0]; + + var result = (IList)expression.Evaluate(); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void IterateParameters_WithOnlyScalarParameters_ReturnsEmptyResultList() + { + var expression = new Expression("[x] + 1", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = 1; + + var result = (IList)expression.Evaluate(); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void IterateParameters_AfterSuccessfulEvaluation_LeavesEnumerableParameterAtLastItem() + { + var expression = new Expression("[x] + [offset]", EvaluateOptions.IterateParameters); + expression.Parameters["x"] = new[] { 1, 2, 3 }; + expression.Parameters["offset"] = 10; + + expression.Evaluate(); + + Assert.AreEqual(3, expression.Parameters["x"]); + Assert.AreEqual(10, expression.Parameters["offset"]); + } + } +} diff --git a/Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs b/Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs new file mode 100644 index 0000000..7057cfb --- /dev/null +++ b/Evaluant.Calculator.Tests/ExpressionPublicApiTests.cs @@ -0,0 +1,176 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NCalc.Domain; + +namespace NCalc.Tests +{ + /// + /// Characterizes the public Expression API, including cache-observable behavior. + /// + [TestClass] + public class ExpressionPublicApiTests + { + [TestMethod] + public void Constructor_WithEmptyString_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression(String.Empty); }); + + Assert.AreEqual("expression", exception.ParamName); + TestSupport.StringContains(exception.Message, "Expression can't be empty"); + } + + [TestMethod] + public void Constructor_WithNullString_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression((string)null); }); + + Assert.AreEqual("expression", exception.ParamName); + TestSupport.StringContains(exception.Message, "Expression can't be empty"); + } + + [TestMethod] + public void Constructor_WithNullLogicalExpression_ThrowsArgumentException() + { + ArgumentException exception = TestSupport.Throws( + delegate { new Expression((LogicalExpression)null); }); + + Assert.AreEqual("expression", exception.ParamName); + TestSupport.StringContains(exception.Message, "Expression can't be null"); + } + + [TestMethod] + public void Constructor_WithLogicalExpression_UsesProvidedExpression() + { + var expression = new Expression( + new BinaryExpression( + BinaryExpressionType.Plus, + new ValueExpression(2), + new ValueExpression(3))); + + Assert.AreEqual(5, expression.Evaluate()); + Assert.IsNotNull(expression.ParsedExpression); + } + + [TestMethod] + public void HasErrors_WithValidExpression_CompilesAndLeavesErrorNull() + { + var expression = new Expression("1 + 2"); + + Assert.IsFalse(expression.HasErrors()); + Assert.IsNotNull(expression.ParsedExpression); + Assert.IsNull(expression.Error); + } + + [TestMethod] + public void HasErrors_WithInvalidExpression_ReturnsTrueAndStoresError() + { + var expression = new Expression("1 +"); + + Assert.IsTrue(expression.HasErrors()); + Assert.IsNotNull(expression.Error); + TestSupport.StringContains(expression.Error, "line"); + } + + [TestMethod] + public void Evaluate_WithInvalidExpression_ThrowsEvaluationExceptionContainingStoredError() + { + var expression = new Expression("(1 + 2"); + + EvaluationException exception = TestSupport.Throws( + delegate { expression.Evaluate(); }); + + Assert.IsNotNull(expression.Error); + Assert.AreEqual(expression.Error, exception.Message); + } + + [TestMethod] + public void Compile_WithCacheEnabled_CompilesRepeatedExpressionsSuccessfully() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = true; + LogicalExpression first = Expression.Compile("100 + 23", false); + LogicalExpression second = Expression.Compile("100 + 23", false); + + Assert.IsNotNull(first); + Assert.IsNotNull(second); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void CacheEnabled_WhenAssigned_RoundTripsAssignedValue() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = false; + Assert.IsFalse(Expression.CacheEnabled); + + Expression.CacheEnabled = true; + Assert.IsTrue(Expression.CacheEnabled); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void Compile_WithNoCacheArgument_DoesNotReuseCompiledExpression() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = true; + LogicalExpression first = Expression.Compile("200 + 23", true); + LogicalExpression second = Expression.Compile("200 + 23", true); + + Assert.IsFalse(Object.ReferenceEquals(first, second)); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void Compile_WithCacheDisabled_DoesNotReuseCompiledExpression() + { + bool originalCacheEnabled = Expression.CacheEnabled; + + try + { + Expression.CacheEnabled = false; + LogicalExpression first = Expression.Compile("300 + 23", false); + LogicalExpression second = Expression.Compile("300 + 23", false); + + Assert.IsFalse(Object.ReferenceEquals(first, second)); + } + finally + { + Expression.CacheEnabled = originalCacheEnabled; + } + } + + [TestMethod] + public void Evaluate_WithNoCacheOption_EvaluatesSuccessfullyWithoutReusingCompileCache() + { + var first = new Expression("400 + 23", EvaluateOptions.NoCache); + var second = new Expression("400 + 23", EvaluateOptions.NoCache); + + Assert.AreEqual(423, first.Evaluate()); + Assert.AreEqual(423, second.Evaluate()); + Assert.IsFalse(Object.ReferenceEquals(first.ParsedExpression, second.ParsedExpression)); + } + } +} diff --git a/Evaluant.Calculator.Tests/ParserSyntaxTests.cs b/Evaluant.Calculator.Tests/ParserSyntaxTests.cs new file mode 100644 index 0000000..993ff9d --- /dev/null +++ b/Evaluant.Calculator.Tests/ParserSyntaxTests.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes parser grammar and operator behavior at the public API boundary. + /// + [TestClass] + public class ParserSyntaxTests + { + [TestMethod] + public void Literals_WithSupportedSyntax_ParseToExpectedValues() + { + object[,] cases = + { + { "123", 123 }, + { "40000000000", 40000000000F }, + { "123.456", 123.456D }, + { "1.22e1", 12.2D }, + { "1e-2", 0.01D }, + { "true", true }, + { "false", false }, + { "'text'", "text" }, + { @"'\u0048\u0065\u006C\u006C\u006F'", "Hello" }, + { @"'line\nbreak'", "line\nbreak" }, + { "#01/01/2001#", new DateTime(2001, 1, 1) } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void Identifiers_WithPlainAndBracketedNames_ReadParameterValues() + { + var expression = new Expression("plain + [customer age] + [_under123]"); + expression.Parameters["plain"] = 1; + expression.Parameters["customer age"] = 2; + expression.Parameters["_under123"] = 3; + + Assert.AreEqual(6, expression.Evaluate()); + } + + [TestMethod] + public void Parser_WithMalformedInputs_ReportsSyntaxErrorsBeforeEvaluation() + { + string[] cases = + { + "(", + "()", + "1 +", + "'unterminated", + "if(true,,1)" + }; + + for (int i = 0; i < cases.Length; i++) + { + var expression = new Expression(cases[i]); + Assert.IsTrue(expression.HasErrors(), cases[i]); + Assert.IsNotNull(expression.Error, cases[i]); + } + } + + [TestMethod] + public void Operators_WithPrecedenceAndAssociativity_EvaluateCurrentGrammarBehavior() + { + object[,] cases = + { + { "2 + 3 * 4", 14 }, + { "(2 + 3) * 4", 20 }, + { "18 / 2 / 2 * 3", 13.5D }, + { "1 + 2 + 3 * 4 / 2", 9D }, + { "2 << 1 + 1", 8 }, + { "8 >> 1 + 1", 2 }, + { "1 | 2 & 3", 3 }, + { "1 ^ 3 & 1", 0 }, + { "~1", -2 }, + { "-1 + 2", 1 }, + { "not false and true", true }, + { "false || true && false", false } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + + [TestMethod] + public void TernaryExpression_WithNestedParenthesizedTernary_EvaluatesExpectedBranch() + { + Assert.AreEqual(2, TestSupport.Evaluate("false ? 1 : (true ? 2 : 3)")); + Assert.AreEqual(1, TestSupport.Evaluate("true ? 1 : (true ? 2 : 3)")); + } + + [TestMethod] + public void ComparisonOperators_WithMixedNumericValues_EvaluateExpectedBooleans() + { + object[,] cases = + { + { "1 = 1.0", true }, + { "1 == 1.0", true }, + { "1 != 1.0", false }, + { "1 <> 2", true }, + { "2 > 1", true }, + { "2 >= 2", true }, + { "1 < 2", true }, + { "1 <= 1", true } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + Assert.AreEqual(cases[i, 1], TestSupport.Evaluate((string)cases[i, 0]), (string)cases[i, 0]); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/SerializationTests.cs b/Evaluant.Calculator.Tests/SerializationTests.cs new file mode 100644 index 0000000..09f58bf --- /dev/null +++ b/Evaluant.Calculator.Tests/SerializationTests.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NCalc.Domain; + +namespace NCalc.Tests +{ + /// + /// Characterizes domain expression serialization through ToString(). + /// + [TestClass] + public class SerializationTests + { + [TestMethod] + public void ToString_WithBinaryOperators_SerializesCurrentOperatorTokens() + { + object[,] cases = + { + { BinaryExpressionType.And, "True and False" }, + { BinaryExpressionType.Or, "True or False" }, + { BinaryExpressionType.Equal, "1 = 2" }, + { BinaryExpressionType.NotEqual, "1 != 2" }, + { BinaryExpressionType.Lesser, "1 < 2" }, + { BinaryExpressionType.LesserOrEqual, "1 <= 2" }, + { BinaryExpressionType.Greater, "1 > 2" }, + { BinaryExpressionType.GreaterOrEqual, "1 >= 2" }, + { BinaryExpressionType.Plus, "1 + 2" }, + { BinaryExpressionType.Minus, "1 - 2" }, + { BinaryExpressionType.Times, "1 * 2" }, + { BinaryExpressionType.Div, "1 / 2" }, + { BinaryExpressionType.Modulo, "1 % 2" }, + { BinaryExpressionType.BitwiseAnd, "1 & 2" }, + { BinaryExpressionType.BitwiseOr, "1 | 2" }, + { BinaryExpressionType.BitwiseXOr, "1 ~ 2" }, + { BinaryExpressionType.LeftShift, "1 << 2" }, + { BinaryExpressionType.RightShift, "1 >> 2" } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + LogicalExpression left = IsBooleanOperator((BinaryExpressionType)cases[i, 0]) + ? (LogicalExpression)new ValueExpression(true) + : new ValueExpression(1); + LogicalExpression right = IsBooleanOperator((BinaryExpressionType)cases[i, 0]) + ? (LogicalExpression)new ValueExpression(false) + : new ValueExpression(2); + + Assert.AreEqual( + cases[i, 1], + new BinaryExpression((BinaryExpressionType)cases[i, 0], left, right).ToString()); + } + } + + [TestMethod] + public void ToString_WithUnaryOperators_SerializesCurrentOperatorTokens() + { + Assert.AreEqual("!True", new UnaryExpression(UnaryExpressionType.Not, new ValueExpression(true)).ToString()); + Assert.AreEqual("-1", new UnaryExpression(UnaryExpressionType.Negate, new ValueExpression(1)).ToString()); + Assert.AreEqual("~1", new UnaryExpression(UnaryExpressionType.BitwiseNot, new ValueExpression(1)).ToString()); + } + + [TestMethod] + public void ToString_WithTernaryExpression_SerializesBranches() + { + var expression = new TernaryExpression( + new ValueExpression(true), + new ValueExpression(1), + new ValueExpression(0)); + + Assert.AreEqual("True ? 1 : 0", expression.ToString()); + } + + [TestMethod] + public void ToString_WithFunctionIdentifierAndValues_SerializesFunctionCall() + { + var expression = new Function( + new Identifier("Mix"), + new LogicalExpression[] + { + new Identifier("name"), + new BinaryExpression(BinaryExpressionType.Plus, new ValueExpression(1), new ValueExpression(2)), + new ValueExpression(new DateTime(2009, 1, 1)) + }); + + Assert.AreEqual("Mix([name], 1 + 2, #" + new DateTime(2009, 1, 1) + "#)", expression.ToString()); + } + + [TestMethod] + public void FluentDomainMethods_WithValueOperands_CreateSerializableBinaryExpressions() + { + Assert.AreEqual("1 + 2", new ValueExpression(1).Plus(2).ToString()); + Assert.AreEqual("1 - 2", new ValueExpression(1).Minus(2).ToString()); + Assert.AreEqual("1 * 2", new ValueExpression(1).Mult(2).ToString()); + Assert.AreEqual("1 / 2", new ValueExpression(1).DividedBy(2).ToString()); + Assert.AreEqual("1 << 2", new ValueExpression(1).LeftShift(2).ToString()); + Assert.AreEqual("1 >> 2", new ValueExpression(1).RightShift(2).ToString()); + } + + private static bool IsBooleanOperator(BinaryExpressionType type) + { + return type == BinaryExpressionType.And || type == BinaryExpressionType.Or; + } + } +} diff --git a/Evaluant.Calculator.Tests/TestSupport.cs b/Evaluant.Calculator.Tests/TestSupport.cs new file mode 100644 index 0000000..cd8e16a --- /dev/null +++ b/Evaluant.Calculator.Tests/TestSupport.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Provides small compatibility assertions for characterization tests. + /// + public static class TestSupport + { + public static object Evaluate(string expression) + { + return new Expression(expression).Evaluate(); + } + + public static TException Throws(Action action) + where TException : Exception + { + try + { + action(); + } + catch (TException exception) + { + return exception; + } + + Assert.Fail("Expected exception of type " + typeof(TException).FullName + "."); + return null; + } + + public static void StringContains(string text, string expectedText) + { + Assert.IsNotNull(text); + Assert.IsTrue( + text.Contains(expectedText), + "Expected <" + text + "> to contain <" + expectedText + ">."); + } + } +} From 49f12a433ceebf72a7e1d074eb50ad0a05439872 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:34:37 +0100 Subject: [PATCH 04/18] Expand numeric behavior coverage --- .../ExpressionNumericBehaviorTests.cs | 67 +++++++++++ .../NumbersArithmeticTests.cs | 84 ++++++++++++++ .../NumbersExtremaTests.cs | 84 ++++++++++++++ .../NumbersInvalidOperationTests.cs | 73 ++++++++++++ Evaluant.Calculator.Tests/NumericTestCase.cs | 107 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs create mode 100644 Evaluant.Calculator.Tests/NumbersArithmeticTests.cs create mode 100644 Evaluant.Calculator.Tests/NumbersExtremaTests.cs create mode 100644 Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs create mode 100644 Evaluant.Calculator.Tests/NumericTestCase.cs diff --git a/Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs b/Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs new file mode 100644 index 0000000..ea09bfc --- /dev/null +++ b/Evaluant.Calculator.Tests/ExpressionNumericBehaviorTests.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class ExpressionNumericBehaviorTests + { + [TestMethod] + public void ExpressionEvaluate_WithNumericLiterals_ReturnsCharacterizedRuntimeTypes() + { + VerifyAll( + new ExpressionResultCase("integer literal", "5", 5, typeof(int)), + new ExpressionResultCase("large integer literal overflow path", "40000000000", 40000000000f, typeof(float)), + new ExpressionResultCase("floating literal", "1.5", 1.5d, typeof(double)), + new ExpressionResultCase("scientific notation", "1.22e1", 12.2d, typeof(double))); + } + + [TestMethod] + public void ExpressionEvaluate_WithNumericArithmetic_ReturnsCharacterizedRuntimeTypes() + { + VerifyAll( + new ExpressionResultCase("integer division is promoted by evaluation visitor", "3/6", 0.5d, typeof(double)), + new ExpressionResultCase("integer modulo remains int", "10 % 4", 2, typeof(int)), + new ExpressionResultCase("unary negation remains int", "-5", -5, typeof(int)), + new ExpressionResultCase("large integer addition uses single", "40000000000+1", 40000000000 + 1f, typeof(float)), + new ExpressionResultCase("string numeric addition uses decimal conversion", "1 + '2'", 3m, typeof(decimal))); + } + + [TestMethod] + public void ExpressionEvaluate_WithDecimalDoubleMixing_ReturnsCharacterizedRuntimeTypes() + { + Expression expression = new Expression("1.8 + Abs([var1])"); + expression.Parameters["var1"] = 9.2; + + object actualValue = expression.Evaluate(); + + Assert.AreEqual(typeof(decimal), actualValue.GetType()); + Assert.AreEqual(11.0m, actualValue); + } + + [TestMethod] + public void ExpressionEvaluate_WithParameterizedRealTypes_PreservesCurrentRealResultTypes() + { + Expression floatExpression = new Expression("x/2"); + floatExpression.Parameters["x"] = 2f; + + Expression doubleExpression = new Expression("x/2"); + doubleExpression.Parameters["x"] = 2d; + + Expression decimalExpression = new Expression("x/2"); + decimalExpression.Parameters["x"] = 2m; + + Assert.AreEqual(typeof(float), floatExpression.Evaluate().GetType()); + Assert.AreEqual(typeof(double), doubleExpression.Evaluate().GetType()); + Assert.AreEqual(typeof(decimal), decimalExpression.Evaluate().GetType()); + } + + private static void VerifyAll(params ExpressionResultCase[] cases) + { + foreach (ExpressionResultCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersArithmeticTests.cs b/Evaluant.Calculator.Tests/NumbersArithmeticTests.cs new file mode 100644 index 0000000..27c5c20 --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersArithmeticTests.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class NumbersArithmeticTests + { + [TestMethod] + public void NumbersAdd_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte plus byte", Numbers.Add, (byte)2, (byte)2, 4, typeof(int)), + new NumericResultCase("byte plus uint", Numbers.Add, (byte)2, (uint)6, (uint)8, typeof(uint)), + new NumericResultCase("byte plus ulong", Numbers.Add, (byte)2, (ulong)8, (ulong)10, typeof(ulong)), + new NumericResultCase("short plus single", Numbers.Add, (short)-3, 1.5f, -1.5f, typeof(float)), + new NumericResultCase("int plus double", Numbers.Add, 5, 2.5d, 7.5d, typeof(double)), + new NumericResultCase("long plus decimal", Numbers.Add, (long)-7, 3.5m, -3.5m, typeof(decimal)), + new NumericResultCase("single plus decimal", Numbers.Add, 1.5f, 3.5m, 5.0m, typeof(decimal)), + new NumericResultCase("double plus decimal", Numbers.Add, 2.5d, 3.5m, 6.0m, typeof(decimal)), + new NumericResultCase("string plus int", Numbers.Add, "4", 5, 9m, typeof(decimal)), + new NumericResultCase("char plus decimal", Numbers.Add, '5', 3.5m, 8.5m, typeof(decimal))); + } + + [TestMethod] + public void NumbersSoustract_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte minus byte falls through", Numbers.Soustract, (byte)2, (byte)2, null, null), + new NumericResultCase("int minus byte falls through", Numbers.Soustract, 5, (byte)2, null, null), + new NumericResultCase("byte minus sbyte", Numbers.Soustract, (byte)2, (sbyte)-2, 4, typeof(int)), + new NumericResultCase("byte minus uint wraps", Numbers.Soustract, (byte)2, (uint)6, UInt32.MaxValue - 3, typeof(uint)), + new NumericResultCase("byte minus ulong wraps", Numbers.Soustract, (byte)2, (ulong)8, UInt64.MaxValue - 5, typeof(ulong)), + new NumericResultCase("uint minus ulong wraps", Numbers.Soustract, (uint)6, (ulong)8, UInt64.MaxValue - 1, typeof(ulong)), + new NumericResultCase("string minus char", Numbers.Soustract, "4", '5', -1m, typeof(decimal))); + } + + [TestMethod] + public void NumbersMultiply_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte times byte falls through", Numbers.Multiply, (byte)2, (byte)2, null, null), + new NumericResultCase("byte times sbyte", Numbers.Multiply, (byte)2, (sbyte)-2, -4, typeof(int)), + new NumericResultCase("ushort times ulong", Numbers.Multiply, (ushort)4, (ulong)8, (ulong)32, typeof(ulong)), + new NumericResultCase("int times double", Numbers.Multiply, 5, 2.5d, 12.5d, typeof(double)), + new NumericResultCase("long times decimal", Numbers.Multiply, (long)-7, 3.5m, -24.5m, typeof(decimal)), + new NumericResultCase("string times char", Numbers.Multiply, "4", '5', 20m, typeof(decimal))); + } + + [TestMethod] + public void NumbersDivide_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte divided by byte falls through", Numbers.Divide, (byte)8, (byte)2, null, null), + new NumericResultCase("byte divided by sbyte", Numbers.Divide, (byte)8, (sbyte)2, 4, typeof(int)), + new NumericResultCase("int divided by int uses integer division", Numbers.Divide, 5, 2, 2, typeof(int)), + new NumericResultCase("int divided by double", Numbers.Divide, 5, 2.5d, 2d, typeof(double)), + new NumericResultCase("uint divided by ulong", Numbers.Divide, (uint)8, (ulong)2, (ulong)4, typeof(ulong)), + new NumericResultCase("decimal divided by int", Numbers.Divide, 3.5m, 5, 0.7m, typeof(decimal)), + new NumericResultCase("string divided by char", Numbers.Divide, "8", '4', 2m, typeof(decimal))); + } + + [TestMethod] + public void NumbersModulo_WithMixedNumericInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("byte modulo byte falls through", Numbers.Modulo, (byte)8, (byte)2, null, null), + new NumericResultCase("byte modulo sbyte", Numbers.Modulo, (byte)8, (sbyte)3, 2, typeof(int)), + new NumericResultCase("int modulo int", Numbers.Modulo, 5, 2, 1, typeof(int)), + new NumericResultCase("uint modulo ulong", Numbers.Modulo, (uint)8, (ulong)3, (ulong)2, typeof(ulong)), + new NumericResultCase("double modulo single", Numbers.Modulo, 5.5d, 2.0f, 1.5d, typeof(double)), + new NumericResultCase("decimal modulo int", Numbers.Modulo, 7.5m, 2, 1.5m, typeof(decimal)), + new NumericResultCase("string modulo char", Numbers.Modulo, "9", '4', 1m, typeof(decimal))); + } + + private static void VerifyAll(params NumericResultCase[] cases) + { + foreach (NumericResultCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersExtremaTests.cs b/Evaluant.Calculator.Tests/NumbersExtremaTests.cs new file mode 100644 index 0000000..a0f0bde --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersExtremaTests.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class NumbersExtremaTests + { + [TestMethod] + public void NumbersMax_WithNullInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("max null null", Numbers.Max, null, null, null, null), + new NumericResultCase("max null int", Numbers.Max, null, 3, 3, typeof(int)), + new NumericResultCase("max int null", Numbers.Max, 3, null, 3, typeof(int))); + } + + [TestMethod] + public void NumbersMin_WithNullInputs_ReturnsCharacterizedValues() + { + VerifyAll( + new NumericResultCase("min null null", Numbers.Min, null, null, null, null), + new NumericResultCase("min null int", Numbers.Min, null, 3, 3, typeof(int)), + new NumericResultCase("min int null", Numbers.Min, 3, null, 3, typeof(int))); + } + + [TestMethod] + public void NumbersMax_UsesLeftOperandTypeForConversionAndResult() + { + VerifyAll( + new NumericResultCase("max bool left returns null", Numbers.Max, true, 2, null, null), + new NumericResultCase("max byte converts right to byte", Numbers.Max, (byte)2, 3.5m, (byte)4, typeof(byte)), + new NumericResultCase("max sbyte converts string to sbyte", Numbers.Max, (sbyte)-2, "4", (sbyte)4, typeof(sbyte)), + new NumericResultCase("max short converts char to short", Numbers.Max, (short)-3, '5', (short)5, typeof(short)), + new NumericResultCase("max uint converts ulong to uint", Numbers.Max, (uint)6, (ulong)8, (uint)8, typeof(uint)), + new NumericResultCase("max long converts bool to long", Numbers.Max, (long)-7, true, (long)1, typeof(long)), + new NumericResultCase("max single converts decimal to single", Numbers.Max, 1.5f, 3.5m, 3.5f, typeof(float)), + new NumericResultCase("max double converts char to double", Numbers.Max, 2.5d, '5', 5d, typeof(double)), + new NumericResultCase("max decimal converts ulong to decimal", Numbers.Max, 3.5m, (ulong)8, 8m, typeof(decimal)), + new NumericResultCase("max string parses left as decimal", Numbers.Max, "4", '5', 5m, typeof(decimal))); + } + + [TestMethod] + public void NumbersMin_UsesLeftOperandTypeForConversionAndResult() + { + VerifyAll( + new NumericResultCase("min bool left returns null", Numbers.Min, true, 2, null, null), + new NumericResultCase("min byte converts bool to byte", Numbers.Min, (byte)2, true, (byte)1, typeof(byte)), + new NumericResultCase("min sbyte keeps left value", Numbers.Min, (sbyte)-2, "4", (sbyte)-2, typeof(sbyte)), + new NumericResultCase("min ushort converts double to ushort", Numbers.Min, (ushort)4, 2.5d, (ushort)2, typeof(ushort)), + new NumericResultCase("min int converts char to int", Numbers.Min, 5, '5', 5, typeof(int)), + new NumericResultCase("min single keeps left value", Numbers.Min, 1.5f, 3.5m, 1.5f, typeof(float)), + new NumericResultCase("min double keeps right converted value", Numbers.Min, 2.5d, 1.5f, 1.5d, typeof(double)), + new NumericResultCase("min decimal converts bool to decimal", Numbers.Min, 3.5m, true, 1m, typeof(decimal)), + new NumericResultCase("min string parses left as decimal", Numbers.Min, "4", '5', 4m, typeof(decimal))); + } + + [TestMethod] + public void NumbersExtrema_WithUnsupportedConversions_ThrowCharacterizedExceptions() + { + VerifyExceptions( + new NumericExceptionCase("max byte with negative sbyte", Numbers.Max, (byte)2, (sbyte)-2, typeof(OverflowException), "unsigned byte"), + new NumericExceptionCase("min byte with negative short", Numbers.Min, (byte)2, (short)-3, typeof(OverflowException), "unsigned byte"), + new NumericExceptionCase("max ulong with negative long", Numbers.Max, (ulong)8, (long)-7, typeof(OverflowException), "UInt64"), + new NumericExceptionCase("min ulong with negative sbyte", Numbers.Min, (ulong)8, (sbyte)-2, typeof(OverflowException), "UInt64")); + } + + private static void VerifyAll(params NumericResultCase[] cases) + { + foreach (NumericResultCase testCase in cases) + { + testCase.Verify(); + } + } + + private static void VerifyExceptions(params NumericExceptionCase[] cases) + { + foreach (NumericExceptionCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs b/Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs new file mode 100644 index 0000000..74eec0b --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersInvalidOperationTests.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class NumbersInvalidOperationTests + { + [TestMethod] + public void NumbersAdd_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("bool plus bool", Numbers.Add, true, true, typeof(InvalidOperationException), "bool' and 'bool"), + new NumericExceptionCase("bool plus decimal reports byte right side", Numbers.Add, true, 3.5m, typeof(InvalidOperationException), "bool' and 'byte"), + new NumericExceptionCase("uint plus bool reports unit typo", Numbers.Add, (uint)6, true, typeof(InvalidOperationException), "unit' and 'bool"), + new NumericExceptionCase("sbyte plus ulong", Numbers.Add, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("long plus ulong", Numbers.Add, (long)-7, (ulong)8, typeof(InvalidOperationException), "long' and 'ulong"), + new NumericExceptionCase("non numeric string plus int", Numbers.Add, "not-a-number", 1, typeof(FormatException), null)); + } + + [TestMethod] + public void NumbersSoustract_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("bool minus bool", Numbers.Soustract, true, true, typeof(InvalidOperationException), "bool' and 'bool"), + new NumericExceptionCase("sbyte minus ulong", Numbers.Soustract, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("ulong minus sbyte reports double typo", Numbers.Soustract, (ulong)8, (sbyte)-2, typeof(InvalidOperationException), "ulong' and 'double"), + new NumericExceptionCase("single minus decimal", Numbers.Soustract, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("decimal minus double", Numbers.Soustract, 3.5m, 2.5d, typeof(InvalidOperationException), "decimal' and 'double")); + } + + [TestMethod] + public void NumbersMultiply_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("byte times bool", Numbers.Multiply, (byte)2, true, typeof(InvalidOperationException), "byte' and 'bool"), + new NumericExceptionCase("sbyte times ulong", Numbers.Multiply, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("ulong times int", Numbers.Multiply, (ulong)8, 5, typeof(InvalidOperationException), "ulong' and 'int"), + new NumericExceptionCase("single times decimal", Numbers.Multiply, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("decimal times double", Numbers.Multiply, 3.5m, 2.5d, typeof(InvalidOperationException), "decimal' and 'double")); + } + + [TestMethod] + public void NumbersDivide_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("byte divided by bool", Numbers.Divide, (byte)8, true, typeof(InvalidOperationException), "byte' and 'bool"), + new NumericExceptionCase("ulong divided by bool reports minus operator", Numbers.Divide, (ulong)8, true, typeof(InvalidOperationException), "Operator '-'"), + new NumericExceptionCase("int divided by ulong", Numbers.Divide, 8, (ulong)2, typeof(InvalidOperationException), "int' and 'ulong"), + new NumericExceptionCase("single divided by decimal", Numbers.Divide, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("double divided by decimal", Numbers.Divide, 2.5d, 3.5m, typeof(InvalidOperationException), "double' and 'decimal")); + } + + [TestMethod] + public void NumbersModulo_WithUnsupportedInputs_ThrowsCharacterizedExceptions() + { + VerifyAll( + new NumericExceptionCase("byte modulo bool", Numbers.Modulo, (byte)8, true, typeof(InvalidOperationException), "byte' and 'bool"), + new NumericExceptionCase("sbyte modulo ulong", Numbers.Modulo, (sbyte)-2, (ulong)8, typeof(InvalidOperationException), "sbyte' and 'ulong"), + new NumericExceptionCase("ulong modulo int", Numbers.Modulo, (ulong)8, 5, typeof(InvalidOperationException), "ulong' and 'int"), + new NumericExceptionCase("single modulo decimal", Numbers.Modulo, 1.5f, 3.5m, typeof(InvalidOperationException), "float' and 'decimal"), + new NumericExceptionCase("decimal modulo double reports decimal twice", Numbers.Modulo, 3.5m, 2.5d, typeof(InvalidOperationException), "decimal' and 'decimal")); + } + + private static void VerifyAll(params NumericExceptionCase[] cases) + { + foreach (NumericExceptionCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumericTestCase.cs b/Evaluant.Calculator.Tests/NumericTestCase.cs new file mode 100644 index 0000000..e1f7988 --- /dev/null +++ b/Evaluant.Calculator.Tests/NumericTestCase.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + internal delegate object NumericOperation(object left, object right); + + internal sealed class NumericResultCase + { + public NumericResultCase(string name, NumericOperation operation, object left, object right, object expectedValue, Type expectedType) + { + Name = name; + Operation = operation; + Left = left; + Right = right; + ExpectedValue = expectedValue; + ExpectedType = expectedType; + } + + private string Name { get; set; } + private NumericOperation Operation { get; set; } + private object Left { get; set; } + private object Right { get; set; } + private object ExpectedValue { get; set; } + private Type ExpectedType { get; set; } + + public void Verify() + { + object actualValue = Operation(Left, Right); + + if (ExpectedType == null) + { + Assert.IsNull(actualValue, Name + " should return null"); + return; + } + + Assert.IsNotNull(actualValue, Name + " should not return null"); + Assert.AreEqual(ExpectedType, actualValue.GetType(), Name + " result type"); + Assert.AreEqual(ExpectedValue, actualValue, Name + " result value"); + } + } + + internal sealed class NumericExceptionCase + { + public NumericExceptionCase(string name, NumericOperation operation, object left, object right, Type expectedExceptionType, string expectedMessageFragment) + { + Name = name; + Operation = operation; + Left = left; + Right = right; + ExpectedExceptionType = expectedExceptionType; + ExpectedMessageFragment = expectedMessageFragment; + } + + private string Name { get; set; } + private NumericOperation Operation { get; set; } + private object Left { get; set; } + private object Right { get; set; } + private Type ExpectedExceptionType { get; set; } + private string ExpectedMessageFragment { get; set; } + + public void Verify() + { + try + { + Operation(Left, Right); + Assert.Fail(Name + " should throw " + ExpectedExceptionType.Name); + } + catch (Exception exception) + { + Assert.AreEqual(ExpectedExceptionType, exception.GetType(), Name + " exception type"); + + if (!String.IsNullOrEmpty(ExpectedMessageFragment)) + { + Assert.IsTrue( + exception.Message.IndexOf(ExpectedMessageFragment) >= 0, + Name + " message should contain '" + ExpectedMessageFragment + "' but was '" + exception.Message + "'"); + } + } + } + } + + internal sealed class ExpressionResultCase + { + public ExpressionResultCase(string name, string expression, object expectedValue, Type expectedType) + { + Name = name; + Expression = expression; + ExpectedValue = expectedValue; + ExpectedType = expectedType; + } + + private string Name { get; set; } + private string Expression { get; set; } + private object ExpectedValue { get; set; } + private Type ExpectedType { get; set; } + + public void Verify() + { + object actualValue = new Expression(Expression).Evaluate(); + + Assert.IsNotNull(actualValue, Name + " should not return null"); + Assert.AreEqual(ExpectedType, actualValue.GetType(), Name + " result type"); + Assert.AreEqual(ExpectedValue, actualValue, Name + " result value"); + } + } +} From 077301175b93199939a6c7750f40f53eb8309224 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:30:37 +0100 Subject: [PATCH 05/18] Add modernization build and release infrastructure --- .github/workflows/ci.yml | 71 ++++++++++++++++++++++++ .github/workflows/release.yml | 100 ++++++++++++++++++++++++++++++++++ .gitignore | 39 +++++++++++++ Directory.Build.props | 19 +++++++ Directory.Build.targets | 5 ++ docs/modernization-release.md | 39 +++++++++++++ 6 files changed, 273 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 docs/modernization-release.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8f44d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + - main + - "codex/**" + workflow_dispatch: + +permissions: + contents: read + +env: + CONFIGURATION: Release + DOTNET_NOLOGO: true + +jobs: + build-test-pack: + name: Restore, build, test, and pack + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Show .NET information + run: dotnet --info + + - name: Restore + run: dotnet restore NCalc.sln + + - name: Build + run: dotnet build NCalc.sln --configuration "$CONFIGURATION" --no-restore + + - name: Test + run: > + dotnet test NCalc.sln + --configuration "$CONFIGURATION" + --no-build + --logger "trx;LogFileName=test-results.trx" + --results-directory TestResults + + - name: Pack + run: > + dotnet pack Evaluant.Calculator/NCalc.csproj + --configuration "$CONFIGURATION" + --no-build + --output artifacts/packages + /p:ContinuousIntegrationBuild=true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: TestResults + if-no-files-found: ignore + + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: artifacts/packages/*.nupkg + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c8d6bb2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: Release Package + +on: + release: + types: + - published + workflow_dispatch: + inputs: + package_version: + description: NuGet package version to build, without a leading v. + required: true + type: string + publish: + description: Publish the package to NuGet.org instead of running a dry pack. + required: true + default: false + type: boolean + +permissions: + contents: read + +env: + CONFIGURATION: Release + DOTNET_NOLOGO: true + +jobs: + package: + name: Build, test, pack, and optionally publish + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Resolve package version + shell: bash + run: | + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + package_version="${GITHUB_REF_NAME#v}" + else + package_version="${{ inputs.package_version }}" + fi + + if [ -z "$package_version" ]; then + echo "Package version could not be resolved." >&2 + exit 1 + fi + + echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" + + - name: Restore + run: dotnet restore NCalc.sln + + - name: Build + run: dotnet build NCalc.sln --configuration "$CONFIGURATION" --no-restore + + - name: Test + run: dotnet test NCalc.sln --configuration "$CONFIGURATION" --no-build + + - name: Pack + run: > + dotnet pack Evaluant.Calculator/NCalc.csproj + --configuration "$CONFIGURATION" + --no-build + --output artifacts/packages + /p:PackageVersion="$PACKAGE_VERSION" + /p:ContinuousIntegrationBuild=true + + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: ncalc-${{ env.PACKAGE_VERSION }} + path: artifacts/packages/*.nupkg + if-no-files-found: error + + - name: Publish to NuGet.org + shell: bash + run: | + should_publish=false + + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + should_publish=true + elif [ "${{ inputs.publish }}" = "true" ]; then + should_publish=true + fi + + if [ "$should_publish" != "true" ]; then + echo "Dry run complete. Package was built but not published." + exit 0 + fi + + dotnet nuget push artifacts/packages/*.nupkg \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..544e383 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Build outputs +[Bb]in/ +[Oo]bj/ +[Bb]uild/ +artifacts/ +TestResults/ +_Package/ + +# User and IDE state +.vs/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.sln.cache +*.rsuser +*ReSharper*/ + +# Test and coverage output +*.trx +*.coverage +*.coveragexml +*.dotCover +*.itrace.csdef + +# NuGet outputs +*.nupkg +*.snupkg +packages/ + +# Patch and merge leftovers +*.patch +*.orig +*.rej + +# OS files +.DS_Store +desktop.ini diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..8780252 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,19 @@ + + + true + + + + NCalc + NCalc + Sebastien Ros + NCalc + Mathematical expressions evaluator for .NET. + ncalc;expression;expressions;calculator;formula;evaluator + https://github.com/sheetsync/NCalc + https://github.com/sheetsync/NCalc.git + git + README.md + false + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..5f112c9 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/modernization-release.md b/docs/modernization-release.md new file mode 100644 index 0000000..782f135 --- /dev/null +++ b/docs/modernization-release.md @@ -0,0 +1,39 @@ +# Modernization Release Notes + +This branch is the release-infrastructure lane for the NCalc modernization stack. It is intended to land after the SDK-style .NET Standard 2.0 project conversion and the modern test-project conversion. + +## Stack Order + +1. Port the core library project to SDK style and `netstandard2.0`. +2. Port the test project to a modern SDK-style test runner. +3. Expand characterization coverage while preserving existing behavior. +4. Add numeric edge-case and API-surface coverage. +5. Add this build, package, and release infrastructure. + +## Local Validation + +After the earlier modernization PRs are applied, maintainers should be able to run: + +```sh +dotnet restore NCalc.sln +dotnet build NCalc.sln --configuration Release --no-restore +dotnet test NCalc.sln --configuration Release --no-build +dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build --output artifacts/packages +``` + +The current legacy project layout still targets .NET Framework 3.5, so these commands depend on the earlier SDK-style conversion PRs. + +## Package Identity + +The shared package metadata preserves the existing NuGet identity as `NCalc` and points package consumers back to `https://github.com/sheetsync/NCalc`. + +No license metadata is declared here because this checkout does not contain a license file or existing license declaration. Add an explicit repository license before publishing a public package version. + +## GitHub Release Flow + +The release workflow builds, tests, packs, uploads the `.nupkg` artifact, and publishes to NuGet.org when either: + +- a GitHub Release is published; or +- the manual workflow is run with `publish` enabled. + +Publishing requires a repository secret named `NUGET_API_KEY`. From 4323d80d9c222575d85d999f4e1c4ab010c683f7 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:33:22 +0100 Subject: [PATCH 06/18] Harden modernization release infrastructure --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++---------- .github/workflows/release.yml | 41 ++++++++++++++++++++++++++--------- Directory.Build.props | 9 ++++++++ docs/modernization-release.md | 38 +++++++++++++++++++++++++++----- 4 files changed, 100 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8f44d3..774d182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,21 @@ permissions: env: CONFIGURATION: Release + CORE_PROJECT: Evaluant.Calculator/NCalc.csproj + TEST_PROJECT: Evaluant.Calculator.Tests/NCalc.Tests.csproj DOTNET_NOLOGO: true jobs: build-test-pack: - name: Restore, build, test, and pack - runs-on: ubuntu-latest + name: Restore, build, test, and pack (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest steps: - name: Check out repository @@ -34,38 +43,46 @@ jobs: run: dotnet --info - name: Restore - run: dotnet restore NCalc.sln + run: | + dotnet restore ${{ env.CORE_PROJECT }} + dotnet restore ${{ env.TEST_PROJECT }} - name: Build - run: dotnet build NCalc.sln --configuration "$CONFIGURATION" --no-restore + run: | + dotnet build ${{ env.CORE_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore + dotnet build ${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore - name: Test run: > - dotnet test NCalc.sln - --configuration "$CONFIGURATION" + dotnet test ${{ env.TEST_PROJECT }} + --configuration ${{ env.CONFIGURATION }} --no-build --logger "trx;LogFileName=test-results.trx" --results-directory TestResults - name: Pack run: > - dotnet pack Evaluant.Calculator/NCalc.csproj - --configuration "$CONFIGURATION" + dotnet pack ${{ env.CORE_PROJECT }} + --configuration ${{ env.CONFIGURATION }} --no-build --output artifacts/packages /p:ContinuousIntegrationBuild=true + /p:IncludeSymbols=true + /p:SymbolPackageFormat=snupkg - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-${{ matrix.os }} path: TestResults if-no-files-found: ignore - name: Upload NuGet package uses: actions/upload-artifact@v4 with: - name: nuget-package - path: artifacts/packages/*.nupkg + name: nuget-package-${{ matrix.os }} + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8d6bb2..126fe42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,8 @@ permissions: env: CONFIGURATION: Release + CORE_PROJECT: Evaluant.Calculator/NCalc.csproj + TEST_PROJECT: Evaluant.Calculator.Tests/NCalc.Tests.csproj DOTNET_NOLOGO: true jobs: @@ -54,28 +56,36 @@ jobs: echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" - name: Restore - run: dotnet restore NCalc.sln + run: | + dotnet restore ${{ env.CORE_PROJECT }} + dotnet restore ${{ env.TEST_PROJECT }} - name: Build - run: dotnet build NCalc.sln --configuration "$CONFIGURATION" --no-restore + run: | + dotnet build ${{ env.CORE_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore + dotnet build ${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore - name: Test - run: dotnet test NCalc.sln --configuration "$CONFIGURATION" --no-build + run: dotnet test ${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-build - name: Pack run: > - dotnet pack Evaluant.Calculator/NCalc.csproj - --configuration "$CONFIGURATION" + dotnet pack ${{ env.CORE_PROJECT }} + --configuration ${{ env.CONFIGURATION }} --no-build --output artifacts/packages /p:PackageVersion="$PACKAGE_VERSION" /p:ContinuousIntegrationBuild=true + /p:IncludeSymbols=true + /p:SymbolPackageFormat=snupkg - name: Upload NuGet package uses: actions/upload-artifact@v4 with: name: ncalc-${{ env.PACKAGE_VERSION }} - path: artifacts/packages/*.nupkg + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg if-no-files-found: error - name: Publish to NuGet.org @@ -94,7 +104,18 @@ jobs: exit 0 fi - dotnet nuget push artifacts/packages/*.nupkg \ - --api-key "${{ secrets.NUGET_API_KEY }}" \ - --source "https://api.nuget.org/v3/index.json" \ - --skip-duplicate + for package in artifacts/packages/*.nupkg; do + dotnet nuget push "$package" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate + done + + for symbols_package in artifacts/packages/*.snupkg; do + if [ -e "$symbols_package" ]; then + dotnet nuget push "$symbols_package" \ + --api-key "${{ secrets.NUGET_API_KEY }}" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate + fi + done diff --git a/Directory.Build.props b/Directory.Build.props index 8780252..abe6085 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,6 @@ + true true @@ -15,5 +16,13 @@ git README.md false + true + snupkg + true + true + + + + diff --git a/docs/modernization-release.md b/docs/modernization-release.md index 782f135..64650f6 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -15,23 +15,49 @@ This branch is the release-infrastructure lane for the NCalc modernization stack After the earlier modernization PRs are applied, maintainers should be able to run: ```sh -dotnet restore NCalc.sln -dotnet build NCalc.sln --configuration Release --no-restore -dotnet test NCalc.sln --configuration Release --no-build -dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build --output artifacts/packages +dotnet restore Evaluant.Calculator/NCalc.csproj +dotnet restore Evaluant.Calculator.Tests/NCalc.Tests.csproj +dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore +dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore +dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build +dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build --output artifacts/packages /p:ContinuousIntegrationBuild=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg ``` -The current legacy project layout still targets .NET Framework 3.5, so these commands depend on the earlier SDK-style conversion PRs. +The pack command should emit both `.nupkg` and `.snupkg` files after the SDK-style project conversion. The current legacy project layout still targets .NET Framework 3.5, so these commands depend on the earlier SDK-style conversion PRs. + +The GitHub CI workflow runs the same restore, build, test, and pack flow on Ubuntu, macOS, and Windows. It validates the library and modern test project directly because the full solution still contains the legacy `Evaluant.Calculator.Play` .NET Framework 3.5 sample project. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. + +## Release Quality Gate + +Release infrastructure changes should not be treated as passing until the branch scores at least 95/100 in coordinator or reviewer assessment. The expected evidence is: + +- clean repository state after validation; +- successful workflow YAML parsing or equivalent linting; +- successful local validation for commands available on the current stack; +- explicit documentation of any stack dependency that prevents full validation; +- no runtime-source changes in this release-infrastructure lane. ## Package Identity The shared package metadata preserves the existing NuGet identity as `NCalc` and points package consumers back to `https://github.com/sheetsync/NCalc`. +This lane owns shared release metadata in `Directory.Build.props` so the SDK-style project conversion can stay focused on target frameworks and dependencies. The metadata is conditioned to the `NCalc` project and SDK-only package references are guarded with `$(UsingMicrosoftNETSdk)` so the current legacy project files are not forced into PackageReference behavior. + +Packages are configured for deterministic builds, Source Link, repository metadata, and `.snupkg` symbol output once the core project is SDK-style. + No license metadata is declared here because this checkout does not contain a license file or existing license declaration. Add an explicit repository license before publishing a public package version. +## Deferred Package Validation And Restore Locking + +Package validation settings are intentionally not enabled in this lane yet. They should be added after the SDK-style project exists and a real package baseline/versioning policy is chosen; enabling package validation without a baseline here would either be untestable on this branch or risk noisy validation with no compatibility signal. + +Restore lock files are also intentionally deferred. Generate and review `packages.lock.json` after PR1 and PR2 have finalized the SDK-style project references and test dependencies; do not check in a hand-written lock file. + +Solution-wide validation should be restored after the sample application is modernized, removed from the solution, or moved behind an explicit Windows/Mono-only gate. + ## GitHub Release Flow -The release workflow builds, tests, packs, uploads the `.nupkg` artifact, and publishes to NuGet.org when either: +The release workflow builds, tests, packs, uploads the `.nupkg` and `.snupkg` artifacts, and publishes them to NuGet.org when either: - a GitHub Release is published; or - the manual workflow is run with `publish` enabled. From 77b13581276a9eef28b6e821707218255c2a5a79 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:35:44 +0100 Subject: [PATCH 07/18] Add coverage and compatibility quality gates --- Evaluant.Calculator.Tests/NCalc.Tests.csproj | 1 + docs/quality-gates.md | 98 ++++++++++++ scripts/consumer-smoke.sh | 91 +++++++++++ scripts/validate.sh | 155 +++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 docs/quality-gates.md create mode 100755 scripts/consumer-smoke.sh create mode 100755 scripts/validate.sh diff --git a/Evaluant.Calculator.Tests/NCalc.Tests.csproj b/Evaluant.Calculator.Tests/NCalc.Tests.csproj index d9c73fa..d75ae1e 100644 --- a/Evaluant.Calculator.Tests/NCalc.Tests.csproj +++ b/Evaluant.Calculator.Tests/NCalc.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 0000000..0f0c4dc --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,98 @@ +# Quality Gates + +This branch adds the repeatable validation gates for the NCalc modernization stack. It is designed to run after the SDK-style core port and the modern test-project migration. + +## Passing Standard + +No modernization lane passes below a 95/100 review score. A lane also fails immediately if it breaks a compatibility must-pass item, even if the numeric score would otherwise pass. + +Required gates: + +- The original behavioral tests pass. +- The expanded characterization tests pass. +- A Cobertura coverage report is produced by `dotnet test`. +- The package builds locally with `dotnet pack`. +- Public API changes require explicit approval and a written compatibility note. +- Consumer smoke tests pass from a local package feed for modern .NET. +- Windows must run a .NET Framework consumer smoke before any public release that claims Framework compatibility. + +## Local Validation + +After stacking on the core port and modern test project, run: + +```sh +scripts/validate.sh +``` + +The script validates the production package and test project directly: + +1. `dotnet restore Evaluant.Calculator/NCalc.csproj` +2. `dotnet restore Evaluant.Calculator.Tests/NCalc.Tests.csproj` +3. `dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore` +4. `dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore` +5. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` +6. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` +7. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package +8. A modern .NET consumer smoke from the packed local package + +Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. + +Local validation packs `NCalc` as `1.3.8-qualitygates` by default. This avoids NuGet reusing a globally cached `NCalc` `1.3.8` baseline package during consumer smoke tests. Override with `PACKAGE_VERSION=...` only when the replacement version is deliberate. + +The full legacy `NCalc.sln` still contains `Evaluant.Calculator.Play`, a .NET Framework 3.5 sample project. Solution-wide validation should be re-enabled after that sample project is either modernized, removed from the solution, or gated to Windows/Mono explicitly. + +## API Compatibility + +The validation script first hydrates the NuGet global-packages cache with the approved baseline package, then uses SDK package validation: + +```sh +baseline_root="$(mktemp -d "${TMPDIR:-/tmp}/ncalc-api-baseline.XXXXXX")" +cat >"${baseline_root}/NCalc.ApiCompatBaseline.csproj" <<'XML' + + + net35 + + + + + +XML + +dotnet restore "${baseline_root}/NCalc.ApiCompatBaseline.csproj" \ + --source https://api.nuget.org/v3/index.json + +dotnet pack Evaluant.Calculator/NCalc.csproj \ + --configuration Release \ + --no-build \ + --output artifacts/packages/api-compat \ + /p:ContinuousIntegrationBuild=true \ + /p:PackageVersion=1.3.8-qualitygates \ + /p:EnablePackageValidation=true \ + /p:PackageValidationBaselineVersion=1.3.8 \ + /p:NoWarn=PKV006 +``` + +`PKV006` is suppressed because the legacy `NCalc` `1.3.8` package places `NCalc.dll` directly under `lib/`, which current SDK package validation interprets as `.NETFramework,Version=v0.0`. The warning suppression is only for that unsupported legacy TFM shape; API breaking-change diagnostics must remain hard failures. + +If the public generated ANTLR parser surface makes package validation too noisy, the failure must be reviewed rather than ignored. Approved changes should be documented with: + +- the old public signature, +- the new public signature, +- why compatibility is still acceptable or why a breaking-change version is required. + +## Consumer Smoke + +Modern .NET smoke is automated: + +```sh +scripts/consumer-smoke.sh artifacts/packages 1.3.8-qualitygates +``` + +Windows .NET Framework smoke is required before publishing. Minimum release checklist: + +1. Pack the branch locally. +2. Create a .NET Framework consumer project on Windows. +3. Restore `NCalc` from the local package feed. +4. Build and run an expression smoke such as `2 * (3 + 5) == 16`. + +This Windows gate is intentionally documented rather than scripted here because this macOS worktree cannot run a .NET Framework executable. diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh new file mode 100755 index 0000000..00889a8 --- /dev/null +++ b/scripts/consumer-smoke.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly PACKAGE_SOURCE="${1:-${REPOSITORY_ROOT}/artifacts/packages}" +readonly PACKAGE_VERSION="${2:-${PACKAGE_VERSION:-}}" +readonly SMOKE_ROOT="${SMOKE_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/ncalc-consumer-smoke.XXXXXX")}" +readonly SMOKE_PROJECT="${SMOKE_ROOT}/NCalc.ConsumerSmoke/NCalc.ConsumerSmoke.csproj" +readonly SMOKE_PACKAGES_DIR="${SMOKE_ROOT}/packages" + +main() { + require_command dotnet + require_package_source + create_consumer_project + add_local_package + write_smoke_program + run_smoke_program +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 127 + fi +} + +require_package_source() { + if [[ -n "${PACKAGE_VERSION}" ]]; then + if ! find "${PACKAGE_SOURCE}" -maxdepth 1 -name "NCalc.${PACKAGE_VERSION}.nupkg" -print -quit | grep -q .; then + echo "NCalc ${PACKAGE_VERSION} package not found in ${PACKAGE_SOURCE}" >&2 + exit 2 + fi + + return + fi + + if ! find "${PACKAGE_SOURCE}" -maxdepth 1 -name 'NCalc.*.nupkg' -print -quit | grep -q .; then + echo "No .nupkg files found in ${PACKAGE_SOURCE}" >&2 + exit 2 + fi +} + +create_consumer_project() { + dotnet new console \ + --framework net10.0 \ + --name NCalc.ConsumerSmoke \ + --output "${SMOKE_ROOT}/NCalc.ConsumerSmoke" \ + >/dev/null +} + +add_local_package() { + local package_arguments=(NCalc --source "${PACKAGE_SOURCE}" --no-restore) + + if [[ -n "${PACKAGE_VERSION}" ]]; then + package_arguments+=(--version "${PACKAGE_VERSION}") + fi + + dotnet add "${SMOKE_PROJECT}" package "${package_arguments[@]}" >/dev/null + + dotnet restore "${SMOKE_PROJECT}" \ + --source "${PACKAGE_SOURCE}" \ + --source "https://api.nuget.org/v3/index.json" \ + /p:RestorePackagesPath="${SMOKE_PACKAGES_DIR}" \ + >/dev/null +} + +write_smoke_program() { + cat >"${SMOKE_ROOT}/NCalc.ConsumerSmoke/Program.cs" <<'CSHARP' +using System; +using NCalc; + +object result = new Expression("2 * (3 + 5)").Evaluate(); + +if (!Equals(16, result)) +{ + throw new InvalidOperationException($"Unexpected NCalc result: {result}"); +} + +Console.WriteLine($"NCalc consumer smoke passed: {result}"); +CSHARP +} + +run_smoke_program() { + dotnet run --project "${SMOKE_PROJECT}" --configuration Release --no-restore +} + +main "$@" diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..a1eb156 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly CONFIGURATION="${CONFIGURATION:-Release}" +readonly COVERAGE_RESULTS_DIR="${COVERAGE_RESULTS_DIR:-${REPOSITORY_ROOT}/artifacts/coverage}" +readonly PACKAGE_OUTPUT_DIR="${PACKAGE_OUTPUT_DIR:-${REPOSITORY_ROOT}/artifacts/packages}" +readonly PACKAGE_VERSION="${PACKAGE_VERSION:-1.3.8-qualitygates}" +readonly API_COMPAT_BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" +readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" +readonly CORE_PROJECT="${CORE_PROJECT:-Evaluant.Calculator/NCalc.csproj}" +readonly TEST_PROJECT="${TEST_PROJECT:-Evaluant.Calculator.Tests/NCalc.Tests.csproj}" + +main() { + cd "${REPOSITORY_ROOT}" + + require_command dotnet + require_sdk_style_core_project + + restore_projects + build_projects + run_tests_with_coverage + pack_package + run_api_compatibility_check + run_modern_consumer_smoke + + print_section "Validation complete" + echo "Quality gates passed locally." +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 127 + fi +} + +require_sdk_style_core_project() { + print_section "Checking stack dependencies" + + if ! grep -q '"${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" < + + net35 + + + + + +XML + + dotnet restore "${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" \ + --source "${NUGET_PACKAGE_SOURCE}" \ + >/dev/null + ) +} + +run_modern_consumer_smoke() { + print_section "Running modern consumer smoke" + + "${SCRIPT_DIR}/consumer-smoke.sh" "${PACKAGE_OUTPUT_DIR}" "${PACKAGE_VERSION}" +} + +print_section() { + local title="$1" + printf '\n==> %s\n' "${title}" +} + +main "$@" From 7f7c49e0d6e69180338c8ff4669f7ee68932fe4a Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:49:46 +0100 Subject: [PATCH 08/18] Deepen numeric behavior coverage --- .../LegacyNumbersMatrix.cs | 388 ++++++++++++++++++ .../NumbersConversionAndNullTests.cs | 82 ++++ .../NumbersPrimitiveMatrixTests.cs | 51 +++ 3 files changed, 521 insertions(+) create mode 100644 Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs create mode 100644 Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs create mode 100644 Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs diff --git a/Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs b/Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs new file mode 100644 index 0000000..14a8bd6 --- /dev/null +++ b/Evaluant.Calculator.Tests/LegacyNumbersMatrix.cs @@ -0,0 +1,388 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + // Defines the legacy numeric dispatch contract exercised by the matrix tests. + internal enum LegacyNumbersOperator + { + Add, + Soustract, + Multiply, + Divide, + Modulo, + Max, + Min + } + + internal sealed class LegacyNumericOperand + { + public LegacyNumericOperand(string name, object value, TypeCode typeCode, string legacyTypeName) + { + Name = name; + Value = value; + TypeCode = typeCode; + LegacyTypeName = legacyTypeName; + } + + public string Name { get; } + public object Value { get; } + public TypeCode TypeCode { get; } + public string LegacyTypeName { get; } + } + + internal sealed class LegacyNumericOutcome + { + private LegacyNumericOutcome(object expectedValue, Type expectedExceptionType, string expectedExceptionMessage, bool returnsNull) + { + ExpectedValue = expectedValue; + ExpectedExceptionType = expectedExceptionType; + ExpectedExceptionMessage = expectedExceptionMessage; + ReturnsNull = returnsNull; + } + + public object ExpectedValue { get; } + public Type ExpectedExceptionType { get; } + public string ExpectedExceptionMessage { get; } + public bool ReturnsNull { get; } + + public bool ThrowsException => ExpectedExceptionType != null; + + public static LegacyNumericOutcome Value(object expectedValue) + { + return new LegacyNumericOutcome(expectedValue, null, null, false); + } + + public static LegacyNumericOutcome Null() + { + return new LegacyNumericOutcome(null, null, null, true); + } + + public static LegacyNumericOutcome Exception(Type expectedExceptionType, string expectedExceptionMessage) + { + return new LegacyNumericOutcome(null, expectedExceptionType, expectedExceptionMessage, false); + } + } + + internal static class LegacyNumbersMatrix + { + private static readonly LegacyNumericOperand[] PrimitiveOperands = + { + new LegacyNumericOperand("bool true", true, TypeCode.Boolean, "bool"), + new LegacyNumericOperand("byte 8", (byte)8, TypeCode.Byte, "byte"), + new LegacyNumericOperand("sbyte -2", (sbyte)-2, TypeCode.SByte, "sbyte"), + new LegacyNumericOperand("short -3", (short)-3, TypeCode.Int16, "short"), + new LegacyNumericOperand("ushort 4", (ushort)4, TypeCode.UInt16, "ushort"), + new LegacyNumericOperand("int 5", 5, TypeCode.Int32, "int"), + new LegacyNumericOperand("uint 6", (uint)6, TypeCode.UInt32, "uint"), + new LegacyNumericOperand("long -7", (long)-7, TypeCode.Int64, "long"), + new LegacyNumericOperand("ulong 8", (ulong)8, TypeCode.UInt64, "ulong"), + new LegacyNumericOperand("float 1.5", 1.5f, TypeCode.Single, "float"), + new LegacyNumericOperand("double 2.5", 2.5d, TypeCode.Double, "double"), + new LegacyNumericOperand("decimal 3.5", 3.5m, TypeCode.Decimal, "decimal") + }; + + public static void VerifyPrimitiveArithmeticMatrix(string operationName, NumericOperation operation, LegacyNumbersOperator numbersOperator) + { + foreach (LegacyNumericOperand left in PrimitiveOperands) + { + foreach (LegacyNumericOperand right in PrimitiveOperands) + { + string caseName = operationName + " with " + left.Name + " and " + right.Name; + LegacyNumericOutcome expectedOutcome = ExpectPrimitiveArithmetic(numbersOperator, left, right); + + VerifyOutcome(caseName, operation, left.Value, right.Value, expectedOutcome); + } + } + } + + public static void VerifyPrimitiveExtremaMatrix(string operationName, NumericOperation operation, LegacyNumbersOperator numbersOperator) + { + foreach (LegacyNumericOperand left in PrimitiveOperands) + { + foreach (LegacyNumericOperand right in PrimitiveOperands) + { + string caseName = operationName + " with " + left.Name + " and " + right.Name; + LegacyNumericOutcome expectedOutcome = ExpectPrimitiveExtrema(numbersOperator, left, right); + + VerifyOutcome(caseName, operation, left.Value, right.Value, expectedOutcome); + } + } + } + + private static LegacyNumericOutcome ExpectPrimitiveArithmetic(LegacyNumbersOperator numbersOperator, LegacyNumericOperand left, LegacyNumericOperand right) + { + switch (numbersOperator) + { + case LegacyNumbersOperator.Add: + return ExpectAdd(left, right); + case LegacyNumbersOperator.Soustract: + return ExpectSoustract(left, right); + case LegacyNumbersOperator.Multiply: + case LegacyNumbersOperator.Divide: + case LegacyNumbersOperator.Modulo: + return ExpectMultiplyDivideOrModulo(numbersOperator, left, right); + default: + throw new InvalidOperationException("Unsupported arithmetic operator: " + numbersOperator); + } + } + + private static LegacyNumericOutcome ExpectAdd(LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Add, "bool", right.TypeCode == TypeCode.Boolean ? "bool" : "byte"); + } + + if (right.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Add, left.TypeCode == TypeCode.UInt32 ? "unit" : left.LegacyTypeName, "bool"); + } + + if (IsSignedIntegral(left) && right.TypeCode == TypeCode.UInt64) + { + return InvalidOperation(LegacyNumbersOperator.Add, left.LegacyTypeName, "ulong"); + } + + if (left.TypeCode == TypeCode.UInt64 && IsSignedIntegral(right)) + { + string rightTypeName = right.TypeCode == TypeCode.Int64 ? "ulong" : right.LegacyTypeName; + return InvalidOperation(LegacyNumbersOperator.Add, "ulong", rightTypeName); + } + + if (IsDecimalRealMix(left, right)) + { + return LegacyNumericOutcome.Value(AddDecimalRealMix(left.Value, right.Value)); + } + + return LegacyNumericOutcome.Value(ApplyArithmetic(LegacyNumbersOperator.Add, left.Value, right.Value)); + } + + private static LegacyNumericOutcome ExpectSoustract(LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, "bool", right.TypeCode == TypeCode.Boolean ? "bool" : "byte"); + } + + if (right.TypeCode == TypeCode.Boolean) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, left.LegacyTypeName, "bool"); + } + + if (right.TypeCode == TypeCode.Byte) + { + return LegacyNumericOutcome.Null(); + } + + if (IsSignedIntegral(left) && right.TypeCode == TypeCode.UInt64) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, left.LegacyTypeName, "ulong"); + } + + if (left.TypeCode == TypeCode.UInt64 && IsSignedIntegral(right)) + { + string rightTypeName = right.TypeCode == TypeCode.SByte ? "double" : right.LegacyTypeName; + return InvalidOperation(LegacyNumbersOperator.Soustract, "ulong", rightTypeName); + } + + if (IsDecimalRealMix(left, right)) + { + return InvalidOperation(LegacyNumbersOperator.Soustract, left.LegacyTypeName, right.LegacyTypeName); + } + + return LegacyNumericOutcome.Value(ApplyArithmetic(LegacyNumbersOperator.Soustract, left.Value, right.Value)); + } + + private static LegacyNumericOutcome ExpectMultiplyDivideOrModulo(LegacyNumbersOperator numbersOperator, LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return LegacyNumericOutcome.Null(); + } + + if (right.TypeCode == TypeCode.Boolean) + { + return left.TypeCode == TypeCode.UInt64 && numbersOperator == LegacyNumbersOperator.Divide + ? InvalidOperationWithSymbol("-", "ulong", "bool") + : InvalidOperation(numbersOperator, left.LegacyTypeName, "bool"); + } + + if (right.TypeCode == TypeCode.Byte) + { + return LegacyNumericOutcome.Null(); + } + + if (IsSignedIntegral(left) && right.TypeCode == TypeCode.UInt64) + { + return InvalidOperation(numbersOperator, left.LegacyTypeName, "ulong"); + } + + if (left.TypeCode == TypeCode.UInt64 && IsSignedIntegral(right)) + { + return InvalidOperation(numbersOperator, "ulong", right.LegacyTypeName); + } + + if (IsDecimalRealMix(left, right)) + { + string rightTypeName = numbersOperator == LegacyNumbersOperator.Modulo && left.TypeCode == TypeCode.Decimal && right.TypeCode == TypeCode.Double + ? "decimal" + : right.LegacyTypeName; + + return InvalidOperation(numbersOperator, left.LegacyTypeName, rightTypeName); + } + + return LegacyNumericOutcome.Value(ApplyArithmetic(numbersOperator, left.Value, right.Value)); + } + + private static LegacyNumericOutcome ExpectPrimitiveExtrema(LegacyNumbersOperator numbersOperator, LegacyNumericOperand left, LegacyNumericOperand right) + { + if (left.TypeCode == TypeCode.Boolean) + { + return LegacyNumericOutcome.Null(); + } + + try + { + object convertedRight = Convert.ChangeType(right.Value, left.Value.GetType()); + object expectedValue = SelectExtrema(numbersOperator, left.Value, convertedRight); + + return LegacyNumericOutcome.Value(expectedValue); + } + catch (Exception exception) + { + return LegacyNumericOutcome.Exception(exception.GetType(), exception.Message); + } + } + + private static void VerifyOutcome(string caseName, NumericOperation operation, object left, object right, LegacyNumericOutcome expectedOutcome) + { + try + { + object actualValue = operation(left, right); + + if (expectedOutcome.ThrowsException) + { + Assert.Fail(caseName + " should throw " + expectedOutcome.ExpectedExceptionType.Name); + } + + if (expectedOutcome.ReturnsNull) + { + Assert.IsNull(actualValue, caseName + " should return null"); + return; + } + + Assert.IsNotNull(actualValue, caseName + " should not return null"); + Assert.AreEqual(expectedOutcome.ExpectedValue.GetType(), actualValue.GetType(), caseName + " result type"); + Assert.AreEqual(expectedOutcome.ExpectedValue, actualValue, caseName + " result value"); + } + catch (Exception exception) + { + if (!expectedOutcome.ThrowsException) + { + Assert.Fail(caseName + " should not throw but threw " + exception.GetType().Name + ": " + exception.Message); + } + + Assert.AreEqual(expectedOutcome.ExpectedExceptionType, exception.GetType(), caseName + " exception type"); + Assert.AreEqual(expectedOutcome.ExpectedExceptionMessage, exception.Message, caseName + " exception message"); + } + } + + private static LegacyNumericOutcome InvalidOperation(LegacyNumbersOperator numbersOperator, string leftTypeName, string rightTypeName) + { + return InvalidOperationWithSymbol(SymbolFor(numbersOperator), leftTypeName, rightTypeName); + } + + private static LegacyNumericOutcome InvalidOperationWithSymbol(string operatorSymbol, string leftTypeName, string rightTypeName) + { + return LegacyNumericOutcome.Exception( + typeof(InvalidOperationException), + "Operator '" + operatorSymbol + "' can't be applied to operands of types '" + leftTypeName + "' and '" + rightTypeName + "'"); + } + + private static string SymbolFor(LegacyNumbersOperator numbersOperator) + { + switch (numbersOperator) + { + case LegacyNumbersOperator.Add: + return "+"; + case LegacyNumbersOperator.Soustract: + return "-"; + case LegacyNumbersOperator.Multiply: + return "*"; + case LegacyNumbersOperator.Divide: + return "/"; + case LegacyNumbersOperator.Modulo: + return "%"; + default: + throw new InvalidOperationException("No arithmetic symbol for " + numbersOperator); + } + } + + private static bool IsSignedIntegral(LegacyNumericOperand operand) + { + return operand.TypeCode == TypeCode.SByte + || operand.TypeCode == TypeCode.Int16 + || operand.TypeCode == TypeCode.Int32 + || operand.TypeCode == TypeCode.Int64; + } + + private static bool IsDecimalRealMix(LegacyNumericOperand left, LegacyNumericOperand right) + { + return (left.TypeCode == TypeCode.Decimal && IsReal(right)) + || (right.TypeCode == TypeCode.Decimal && IsReal(left)); + } + + private static bool IsReal(LegacyNumericOperand operand) + { + return operand.TypeCode == TypeCode.Single || operand.TypeCode == TypeCode.Double; + } + + private static object AddDecimalRealMix(object leftValue, object rightValue) + { + if (leftValue is decimal leftDecimal) + { + return leftDecimal + Convert.ToDecimal(rightValue); + } + + return Convert.ToDecimal(leftValue) + (decimal)rightValue; + } + + private static object ApplyArithmetic(LegacyNumbersOperator numbersOperator, object leftValue, object rightValue) + { + dynamic left = leftValue; + dynamic right = rightValue; + + switch (numbersOperator) + { + case LegacyNumbersOperator.Add: + return left + right; + case LegacyNumbersOperator.Soustract: + return left - right; + case LegacyNumbersOperator.Multiply: + return left * right; + case LegacyNumbersOperator.Divide: + return left / right; + case LegacyNumbersOperator.Modulo: + return left % right; + default: + throw new InvalidOperationException("Unsupported arithmetic operator: " + numbersOperator); + } + } + + private static object SelectExtrema(LegacyNumbersOperator numbersOperator, object leftValue, object convertedRight) + { + int comparison = ((IComparable)leftValue).CompareTo(convertedRight); + + switch (numbersOperator) + { + case LegacyNumbersOperator.Max: + return comparison >= 0 ? leftValue : convertedRight; + case LegacyNumbersOperator.Min: + return comparison <= 0 ? leftValue : convertedRight; + default: + throw new InvalidOperationException("Unsupported extrema operator: " + numbersOperator); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs b/Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs new file mode 100644 index 0000000..d6d749b --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersConversionAndNullTests.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + // Characterizes conversion and null behavior around the numeric switchboard. + [TestClass] + public class NumbersConversionAndNullTests + { + [TestMethod] + public void NumbersArithmetic_WithStringAndCharacterOperands_DoesParseOperandsAsDecimalsBeforeDispatch() + { + VerifyResults( + new NumericResultCase("add character plus string as decimals", Numbers.Add, '5', "4", 9m, typeof(decimal)), + new NumericResultCase("add string plus double through decimal conversion", Numbers.Add, "4", 2.5d, 6.5m, typeof(decimal)), + new NumericResultCase("soustract character minus string as decimals", Numbers.Soustract, '5', "4", 1m, typeof(decimal)), + new NumericResultCase("multiply character by string as decimals", Numbers.Multiply, '5', "4", 20m, typeof(decimal)), + new NumericResultCase("divide character by string as decimals", Numbers.Divide, '5', "4", 1.25m, typeof(decimal)), + new NumericResultCase("modulo character by string as decimals", Numbers.Modulo, '5', "4", 1m, typeof(decimal)), + new NumericResultCase("modulo decimal by character as decimals", Numbers.Modulo, 3.5m, '5', 3.5m, typeof(decimal))); + } + + [TestMethod] + public void NumbersArithmetic_WithInvalidStringOperand_DoesThrowFormatExceptionBeforeDispatch() + { + VerifyExceptions( + new NumericExceptionCase("add invalid string", Numbers.Add, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("soustract invalid string", Numbers.Soustract, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("multiply invalid string", Numbers.Multiply, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("divide invalid string", Numbers.Divide, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("modulo invalid string", Numbers.Modulo, "not-a-number", 1, typeof(FormatException), null), + new NumericExceptionCase("max invalid right string parses before bool fallthrough", Numbers.Max, true, "not-a-number", typeof(FormatException), null), + new NumericExceptionCase("min invalid left string parses before null handling", Numbers.Min, "not-a-number", null, typeof(FormatException), null)); + } + + [TestMethod] + public void NumbersArithmetic_WithNullOperand_DoesThrowNullReferenceExceptionBeforeDispatch() + { + VerifyExceptions( + new NumericExceptionCase("add null left", Numbers.Add, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("add null right", Numbers.Add, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("soustract null left", Numbers.Soustract, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("soustract null right", Numbers.Soustract, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("multiply null left", Numbers.Multiply, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("multiply null right", Numbers.Multiply, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("divide null left", Numbers.Divide, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("divide null right", Numbers.Divide, 1, null, typeof(NullReferenceException), null), + new NumericExceptionCase("modulo null left", Numbers.Modulo, null, 1, typeof(NullReferenceException), null), + new NumericExceptionCase("modulo null right", Numbers.Modulo, 1, null, typeof(NullReferenceException), null)); + } + + [TestMethod] + public void NumbersExtrema_WithNullAndConvertedOperands_DoesReturnConvertedNonNullOperand() + { + VerifyResults( + new NumericResultCase("max null string parses right before null return", Numbers.Max, null, "4", 4m, typeof(decimal)), + new NumericResultCase("min null character parses right before null return", Numbers.Min, null, '5', 5m, typeof(decimal)), + new NumericResultCase("max string null parses left before null return", Numbers.Max, "4", null, 4m, typeof(decimal)), + new NumericResultCase("min character null parses left before null return", Numbers.Min, '5', null, 5m, typeof(decimal)), + new NumericResultCase("max bool null returns bool before unsupported switch", Numbers.Max, true, null, true, typeof(bool)), + new NumericResultCase("min null bool returns bool before unsupported switch", Numbers.Min, null, true, true, typeof(bool)), + new NumericResultCase("max bool int still falls through", Numbers.Max, true, 1, null, null), + new NumericResultCase("min bool int still falls through", Numbers.Min, true, 1, null, null)); + } + + private static void VerifyResults(params NumericResultCase[] cases) + { + foreach (NumericResultCase testCase in cases) + { + testCase.Verify(); + } + } + + private static void VerifyExceptions(params NumericExceptionCase[] cases) + { + foreach (NumericExceptionCase testCase in cases) + { + testCase.Verify(); + } + } + } +} diff --git a/Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs b/Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs new file mode 100644 index 0000000..98c1c12 --- /dev/null +++ b/Evaluant.Calculator.Tests/NumbersPrimitiveMatrixTests.cs @@ -0,0 +1,51 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + // Exercises the full primitive numeric switchboard without changing runtime behavior. + [TestClass] + public class NumbersPrimitiveMatrixTests + { + [TestMethod] + public void NumbersAdd_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Add", Numbers.Add, LegacyNumbersOperator.Add); + } + + [TestMethod] + public void NumbersSoustract_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Soustract", Numbers.Soustract, LegacyNumbersOperator.Soustract); + } + + [TestMethod] + public void NumbersMultiply_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsNullsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Multiply", Numbers.Multiply, LegacyNumbersOperator.Multiply); + } + + [TestMethod] + public void NumbersDivide_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsNullsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Divide", Numbers.Divide, LegacyNumbersOperator.Divide); + } + + [TestMethod] + public void NumbersModulo_WithPrimitiveNumericMatrix_DoesPreserveLegacyResultsNullsAndExceptions() + { + LegacyNumbersMatrix.VerifyPrimitiveArithmeticMatrix("Numbers.Modulo", Numbers.Modulo, LegacyNumbersOperator.Modulo); + } + + [TestMethod] + public void NumbersMax_WithPrimitiveNumericMatrix_DoesPreserveLeftOperandConversionRules() + { + LegacyNumbersMatrix.VerifyPrimitiveExtremaMatrix("Numbers.Max", Numbers.Max, LegacyNumbersOperator.Max); + } + + [TestMethod] + public void NumbersMin_WithPrimitiveNumericMatrix_DoesPreserveLeftOperandConversionRules() + { + LegacyNumbersMatrix.VerifyPrimitiveExtremaMatrix("Numbers.Min", Numbers.Min, LegacyNumbersOperator.Min); + } + } +} From 1ccd57ce7f9d7d985b4c73739394f295cb0711b7 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 10:48:50 +0100 Subject: [PATCH 09/18] Expand domain surface coverage --- .../DomainPublicSurfaceTests.cs | 458 ++++++++++++++++++ .../EvaluationExceptionTests.cs | 34 ++ 2 files changed, 492 insertions(+) create mode 100644 Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs create mode 100644 Evaluant.Calculator.Tests/EvaluationExceptionTests.cs diff --git a/Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs b/Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs new file mode 100644 index 0000000..3277458 --- /dev/null +++ b/Evaluant.Calculator.Tests/DomainPublicSurfaceTests.cs @@ -0,0 +1,458 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NCalc.Domain; +using DomainValueType = NCalc.Domain.ValueType; + +namespace NCalc.Tests +{ + /// + /// Characterizes the public AST/domain model shared by parser, serializer, and evaluator. + /// + [TestClass] + public class DomainPublicSurfaceTests + { + [TestMethod] + public void BinaryExpression_WithConstructorArguments_ExposesMutableProperties() + { + LogicalExpression leftExpression = new ValueExpression(1); + LogicalExpression rightExpression = new ValueExpression(2); + var expression = new BinaryExpression(BinaryExpressionType.Plus, leftExpression, rightExpression); + + Assert.AreEqual(BinaryExpressionType.Plus, expression.Type); + Assert.AreSame(leftExpression, expression.LeftExpression); + Assert.AreSame(rightExpression, expression.RightExpression); + + LogicalExpression replacementLeftExpression = new Identifier("left"); + LogicalExpression replacementRightExpression = new Identifier("right"); + expression.Type = BinaryExpressionType.Minus; + expression.LeftExpression = replacementLeftExpression; + expression.RightExpression = replacementRightExpression; + + Assert.AreEqual(BinaryExpressionType.Minus, expression.Type); + Assert.AreSame(replacementLeftExpression, expression.LeftExpression); + Assert.AreSame(replacementRightExpression, expression.RightExpression); + } + + [TestMethod] + public void UnaryExpression_WithConstructorArguments_ExposesMutableProperties() + { + LogicalExpression innerExpression = new ValueExpression(1); + var expression = new UnaryExpression(UnaryExpressionType.Negate, innerExpression); + + Assert.AreEqual(UnaryExpressionType.Negate, expression.Type); + Assert.AreSame(innerExpression, expression.Expression); + + LogicalExpression replacementExpression = new Identifier("flag"); + expression.Type = UnaryExpressionType.Not; + expression.Expression = replacementExpression; + + Assert.AreEqual(UnaryExpressionType.Not, expression.Type); + Assert.AreSame(replacementExpression, expression.Expression); + } + + [TestMethod] + public void TernaryExpression_WithConstructorArguments_ExposesMutableProperties() + { + LogicalExpression conditionExpression = new Identifier("condition"); + LogicalExpression trueExpression = new ValueExpression(1); + LogicalExpression falseExpression = new ValueExpression(0); + var expression = new TernaryExpression(conditionExpression, trueExpression, falseExpression); + + Assert.AreSame(conditionExpression, expression.LeftExpression); + Assert.AreSame(trueExpression, expression.MiddleExpression); + Assert.AreSame(falseExpression, expression.RightExpression); + + LogicalExpression replacementConditionExpression = new ValueExpression(true); + LogicalExpression replacementTrueExpression = new Identifier("trueValue"); + LogicalExpression replacementFalseExpression = new Identifier("falseValue"); + expression.LeftExpression = replacementConditionExpression; + expression.MiddleExpression = replacementTrueExpression; + expression.RightExpression = replacementFalseExpression; + + Assert.AreSame(replacementConditionExpression, expression.LeftExpression); + Assert.AreSame(replacementTrueExpression, expression.MiddleExpression); + Assert.AreSame(replacementFalseExpression, expression.RightExpression); + } + + [TestMethod] + public void Function_WithConstructorArguments_ExposesMutableProperties() + { + var identifier = new Identifier("Sum"); + var arguments = new LogicalExpression[] + { + new ValueExpression(1), + new ValueExpression(2) + }; + var function = new Function(identifier, arguments); + + Assert.AreSame(identifier, function.Identifier); + Assert.AreSame(arguments, function.Expressions); + + var replacementIdentifier = new Identifier("Max"); + var replacementArguments = new LogicalExpression[] + { + new Identifier("amount"), + new ValueExpression(10) + }; + function.Identifier = replacementIdentifier; + function.Expressions = replacementArguments; + + Assert.AreSame(replacementIdentifier, function.Identifier); + Assert.AreSame(replacementArguments, function.Expressions); + } + + [TestMethod] + public void Identifier_WithConstructorArguments_ExposesMutableProperties() + { + var identifier = new Identifier("original"); + + Assert.AreEqual("original", identifier.Name); + + identifier.Name = "replacement"; + + Assert.AreEqual("replacement", identifier.Name); + } + + [TestMethod] + public void ValueExpression_WithExplicitTypeConstructor_ExposesMutableProperties() + { + var expression = new ValueExpression("123", DomainValueType.Integer); + + Assert.AreEqual("123", expression.Value); + Assert.AreEqual(DomainValueType.Integer, expression.Type); + + expression.Value = "abc"; + expression.Type = DomainValueType.String; + + Assert.AreEqual("abc", expression.Value); + Assert.AreEqual(DomainValueType.String, expression.Type); + } + + [TestMethod] + public void ValueExpression_WithStronglyTypedConstructors_InferExpectedTypes() + { + DateTime dateTime = new DateTime(2026, 6, 20); + + AssertValueExpression(new ValueExpression("hello"), "hello", DomainValueType.String); + AssertValueExpression(new ValueExpression(42), 42, DomainValueType.Integer); + AssertValueExpression(new ValueExpression(1.5F), 1.5F, DomainValueType.Float); + AssertValueExpression(new ValueExpression(dateTime), dateTime, DomainValueType.DateTime); + AssertValueExpression(new ValueExpression(true), true, DomainValueType.Boolean); + } + + [TestMethod] + public void ValueExpression_WithObjectConstructor_InferExpectedTypes() + { + object[,] cases = + { + { true, DomainValueType.Boolean }, + { new DateTime(2026, 6, 20), DomainValueType.DateTime }, + { 1.5M, DomainValueType.Float }, + { 1.5D, DomainValueType.Float }, + { 1.5F, DomainValueType.Float }, + { (byte)1, DomainValueType.Integer }, + { (sbyte)1, DomainValueType.Integer }, + { (short)1, DomainValueType.Integer }, + { 1, DomainValueType.Integer }, + { 1L, DomainValueType.Integer }, + { (ushort)1, DomainValueType.Integer }, + { 1U, DomainValueType.Integer }, + { 1UL, DomainValueType.Integer }, + { "hello", DomainValueType.String } + }; + + for (int index = 0; index < cases.GetLength(0); index++) + { + object value = cases[index, 0]; + var expression = new ValueExpression(value); + + Assert.AreSame(value, expression.Value); + Assert.AreEqual(cases[index, 1], expression.Type); + } + } + + [TestMethod] + public void ValueExpression_WithUnsupportedObject_ThrowsEvaluationException() + { + var unsupportedValue = new object(); + + EvaluationException exception = TestSupport.Throws( + delegate { new ValueExpression(unsupportedValue); }); + + Assert.AreEqual("This value could not be handled: " + unsupportedValue, exception.Message); + } + + [TestMethod] + public void Accept_WithConcreteExpressions_DispatchesToSpecificVisitorOverload() + { + AssertVisit( + new BinaryExpression(BinaryExpressionType.Plus, new ValueExpression(1), new ValueExpression(2)), + typeof(BinaryExpression)); + AssertVisit( + new UnaryExpression(UnaryExpressionType.Negate, new ValueExpression(1)), + typeof(UnaryExpression)); + AssertVisit( + new TernaryExpression(new ValueExpression(true), new ValueExpression(1), new ValueExpression(0)), + typeof(TernaryExpression)); + AssertVisit(new ValueExpression(1), typeof(ValueExpression)); + AssertVisit( + new Function(new Identifier("Sum"), new LogicalExpression[] { new ValueExpression(1) }), + typeof(Function)); + AssertVisit(new Identifier("amount"), typeof(Identifier)); + } + + [TestMethod] + public void Accept_WithBareLogicalExpression_DispatchesToBaseVisitorOverload() + { + LogicalExpression expression = new BareLogicalExpression(); + + AssertVisit(expression, typeof(LogicalExpression)); + } + + [TestMethod] + public void FluentMethods_WithLogicalExpressionOperands_CreateBinaryExpressionsWithExpectedShape() + { + foreach (FluentBinaryExpressionCase expressionCase in GetFluentBinaryExpressionCases()) + { + LogicalExpression leftExpression = new Identifier("left"); + LogicalExpression rightExpression = new Identifier("right"); + + BinaryExpression expression = expressionCase.CreateWithLogicalOperand(leftExpression, rightExpression); + + Assert.AreEqual(expressionCase.ExpectedType, expression.Type); + Assert.AreSame(leftExpression, expression.LeftExpression); + Assert.AreSame(rightExpression, expression.RightExpression); + } + } + + [TestMethod] + public void FluentMethods_WithObjectOperands_WrapRightOperandAsValueExpression() + { + foreach (FluentBinaryExpressionCase expressionCase in GetFluentBinaryExpressionCases()) + { + LogicalExpression leftExpression = new Identifier("left"); + + BinaryExpression expression = expressionCase.CreateWithValueOperand(leftExpression, 2); + + Assert.AreEqual(expressionCase.ExpectedType, expression.Type); + Assert.AreSame(leftExpression, expression.LeftExpression); + Assert.IsInstanceOfType(expression.RightExpression, typeof(ValueExpression)); + + var rightExpression = (ValueExpression)expression.RightExpression; + Assert.AreEqual(2, rightExpression.Value); + Assert.AreEqual(DomainValueType.Integer, rightExpression.Type); + } + } + + [TestMethod] + public void ExpressionEvaluation_WithManualDomainTree_EvaluatesParametersFunctionsAndBranches() + { + LogicalExpression conditionExpression = new Identifier("amount").GreaterThan(100); + LogicalExpression trueExpression = new Function( + new Identifier("Max"), + new LogicalExpression[] + { + new Identifier("amount"), + new ValueExpression(250) + }); + LogicalExpression falseExpression = new ValueExpression(0); + var expression = new Expression(new TernaryExpression(conditionExpression, trueExpression, falseExpression)); + + expression.Parameters["amount"] = 125; + Assert.AreEqual(250, expression.Evaluate()); + + expression.Parameters["amount"] = 75; + Assert.AreEqual(0, expression.Evaluate()); + } + + private static void AssertValueExpression(ValueExpression expression, object expectedValue, DomainValueType expectedType) + { + Assert.AreEqual(expectedValue, expression.Value); + Assert.AreEqual(expectedType, expression.Type); + } + + private static void AssertVisit(LogicalExpression expression, Type expectedVisitType) + { + var visitor = new RecordingLogicalExpressionVisitor(); + + expression.Accept(visitor); + + Assert.AreEqual(1, visitor.VisitCount); + Assert.AreSame(expression, visitor.LastExpression); + Assert.AreEqual(expectedVisitType, visitor.LastVisitType); + } + + private static IEnumerable GetFluentBinaryExpressionCases() + { + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.And, + (left, right) => left.And(right), + (left, value) => left.And(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Or, + (left, right) => left.Or(right), + (left, value) => left.Or(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Equal, + (left, right) => left.EqualsTo(right), + (left, value) => left.EqualsTo(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.NotEqual, + (left, right) => left.NotEqual(right), + (left, value) => left.NotEqual(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Greater, + (left, right) => left.GreaterThan(right), + (left, value) => left.GreaterThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.GreaterOrEqual, + (left, right) => left.GreaterOrEqualThan(right), + (left, value) => left.GreaterOrEqualThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Lesser, + (left, right) => left.LesserThan(right), + (left, value) => left.LesserThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.LesserOrEqual, + (left, right) => left.LesserOrEqualThan(right), + (left, value) => left.LesserOrEqualThan(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Minus, + (left, right) => left.Minus(right), + (left, value) => left.Minus(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Plus, + (left, right) => left.Plus(right), + (left, value) => left.Plus(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Times, + (left, right) => left.Mult(right), + (left, value) => left.Mult(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Div, + (left, right) => left.DividedBy(right), + (left, value) => left.DividedBy(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.Modulo, + (left, right) => left.Modulo(right), + (left, value) => left.Modulo(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.BitwiseOr, + (left, right) => left.BitwiseOr(right), + (left, value) => left.BitwiseOr(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.BitwiseAnd, + (left, right) => left.BitwiseAnd(right), + (left, value) => left.BitwiseAnd(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.BitwiseXOr, + (left, right) => left.BitwiseXOr(right), + (left, value) => left.BitwiseXOr(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.LeftShift, + (left, right) => left.LeftShift(right), + (left, value) => left.LeftShift(value)); + yield return CreateFluentBinaryExpressionCase( + BinaryExpressionType.RightShift, + (left, right) => left.RightShift(right), + (left, value) => left.RightShift(value)); + } + + private static FluentBinaryExpressionCase CreateFluentBinaryExpressionCase( + BinaryExpressionType expectedType, + LogicalOperandFactory logicalOperandFactory, + ValueOperandFactory valueOperandFactory) + { + return new FluentBinaryExpressionCase(expectedType, logicalOperandFactory, valueOperandFactory); + } + + private delegate BinaryExpression LogicalOperandFactory(LogicalExpression leftExpression, LogicalExpression rightExpression); + + private delegate BinaryExpression ValueOperandFactory(LogicalExpression leftExpression, object rightValue); + + private sealed class FluentBinaryExpressionCase + { + public FluentBinaryExpressionCase( + BinaryExpressionType expectedType, + LogicalOperandFactory logicalOperandFactory, + ValueOperandFactory valueOperandFactory) + { + ExpectedType = expectedType; + LogicalOperandFactory = logicalOperandFactory; + ValueOperandFactory = valueOperandFactory; + } + + public BinaryExpressionType ExpectedType { get; private set; } + + private LogicalOperandFactory LogicalOperandFactory { get; set; } + + private ValueOperandFactory ValueOperandFactory { get; set; } + + public BinaryExpression CreateWithLogicalOperand(LogicalExpression leftExpression, LogicalExpression rightExpression) + { + return LogicalOperandFactory(leftExpression, rightExpression); + } + + public BinaryExpression CreateWithValueOperand(LogicalExpression leftExpression, object rightValue) + { + return ValueOperandFactory(leftExpression, rightValue); + } + } + + private sealed class RecordingLogicalExpressionVisitor : LogicalExpressionVisitor + { + public int VisitCount { get; private set; } + + public Type LastVisitType { get; private set; } + + public LogicalExpression LastExpression { get; private set; } + + public override void Visit(LogicalExpression expression) + { + RecordVisit(typeof(LogicalExpression), expression); + } + + public override void Visit(TernaryExpression expression) + { + RecordVisit(typeof(TernaryExpression), expression); + } + + public override void Visit(BinaryExpression expression) + { + RecordVisit(typeof(BinaryExpression), expression); + } + + public override void Visit(UnaryExpression expression) + { + RecordVisit(typeof(UnaryExpression), expression); + } + + public override void Visit(ValueExpression expression) + { + RecordVisit(typeof(ValueExpression), expression); + } + + public override void Visit(Function function) + { + RecordVisit(typeof(Function), function); + } + + public override void Visit(Identifier function) + { + RecordVisit(typeof(Identifier), function); + } + + private void RecordVisit(Type visitType, LogicalExpression expression) + { + VisitCount++; + LastVisitType = visitType; + LastExpression = expression; + } + } + + private sealed class BareLogicalExpression : LogicalExpression + { + } + } +} diff --git a/Evaluant.Calculator.Tests/EvaluationExceptionTests.cs b/Evaluant.Calculator.Tests/EvaluationExceptionTests.cs new file mode 100644 index 0000000..8578c00 --- /dev/null +++ b/Evaluant.Calculator.Tests/EvaluationExceptionTests.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + /// + /// Characterizes the public EvaluationException constructors. + /// + [TestClass] + public class EvaluationExceptionTests + { + [TestMethod] + public void Constructor_WithMessage_SetsMessageAndLeavesInnerExceptionNull() + { + var exception = new EvaluationException("evaluation failed"); + + Assert.IsInstanceOfType(exception, typeof(ApplicationException)); + Assert.AreEqual("evaluation failed", exception.Message); + Assert.IsNull(exception.InnerException); + } + + [TestMethod] + public void Constructor_WithMessageAndInnerException_SetsMessageAndInnerException() + { + var innerException = new InvalidOperationException("inner failure"); + + var exception = new EvaluationException("evaluation failed", innerException); + + Assert.IsInstanceOfType(exception, typeof(ApplicationException)); + Assert.AreEqual("evaluation failed", exception.Message); + Assert.AreSame(innerException, exception.InnerException); + } + } +} From a3a7bd917987632fd2f3b1cf661c7c73ad0f73c3 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 11:03:59 +0100 Subject: [PATCH 10/18] Add cross-target framework compatibility gates --- .github/workflows/ci.yml | 21 ++ .github/workflows/release.yml | 26 +- .../NCalc.FrameworkCompatibility.Tests.csproj | 19 ++ .../Program.cs | 283 ++++++++++++++++++ docs/modernization-release.md | 6 +- docs/quality-gates.md | 19 +- scripts/validate.sh | 26 ++ 7 files changed, 372 insertions(+), 28 deletions(-) create mode 100644 Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj create mode 100644 Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 774d182..0db1941 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,27 @@ env: DOTNET_NOLOGO: true jobs: + quality-gates: + name: Local quality gates and compatibility + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install Mono runtime + run: | + sudo apt-get update + sudo apt-get install --yes mono-runtime + + - name: Run local validation gates + run: scripts/validate.sh + build-test-pack: name: Restore, build, test, and pack (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 126fe42..58eb34b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,29 +55,13 @@ jobs: echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" - - name: Restore + - name: Install Mono runtime run: | - dotnet restore ${{ env.CORE_PROJECT }} - dotnet restore ${{ env.TEST_PROJECT }} + sudo apt-get update + sudo apt-get install --yes mono-runtime - - name: Build - run: | - dotnet build ${{ env.CORE_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore - dotnet build ${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-restore - - - name: Test - run: dotnet test ${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-build - - - name: Pack - run: > - dotnet pack ${{ env.CORE_PROJECT }} - --configuration ${{ env.CONFIGURATION }} - --no-build - --output artifacts/packages - /p:PackageVersion="$PACKAGE_VERSION" - /p:ContinuousIntegrationBuild=true - /p:IncludeSymbols=true - /p:SymbolPackageFormat=snupkg + - name: Run release validation gates + run: scripts/validate.sh - name: Upload NuGet package uses: actions/upload-artifact@v4 diff --git a/Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj b/Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj new file mode 100644 index 0000000..4e7e651 --- /dev/null +++ b/Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj @@ -0,0 +1,19 @@ + + + Exe + net35;net10.0 + NCalc.FrameworkCompatibility.Tests + NCalc.FrameworkCompatibility.Tests + disable + disable + latest + + + + + + + + + + diff --git a/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs b/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs new file mode 100644 index 0000000..b30b626 --- /dev/null +++ b/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs @@ -0,0 +1,283 @@ +using System; +using System.Globalization; +using System.Threading; +using NCalc.Domain; +using DomainValueType = NCalc.Domain.ValueType; + +namespace NCalc.FrameworkCompatibility.Tests +{ + internal delegate object BinaryOperation(object left, object right); + + internal static class Program + { + private static int _passed; + private static int _failed; + + private static int Main() + { + CultureInfo originalCulture = Thread.CurrentThread.CurrentCulture; + CultureInfo originalUserInterfaceCulture = Thread.CurrentThread.CurrentUICulture; + + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("fr-FR"); + + RunExpressionCases(); + RunNumbersCases(); + RunDomainCases(); + RunCallbackCases(); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + Thread.CurrentThread.CurrentUICulture = originalUserInterfaceCulture; + } + + if (_failed > 0) + { + Console.Error.WriteLine("NCalc framework compatibility failed: " + _failed + " failed, " + _passed + " passed."); + return 1; + } + + Console.WriteLine("NCalc framework compatibility passed: " + _passed + " assertions."); + return 0; + } + + private static void RunExpressionCases() + { + Run("ExpressionEvaluate_WithInvariantFloatLiteralUnderFrenchCulture_DoesReturnDouble", delegate + { + AssertExpression("1.5 + 2.25", 3.75d, typeof(double)); + }); + + Run("ExpressionEvaluate_WithLargeIntegerLiteral_DoesPreserveSingleOverflowPath", delegate + { + AssertExpression("40000000000", 40000000000f, typeof(float)); + }); + + Run("ExpressionEvaluate_WithDateLiteralUnderFrenchCulture_DoesReturnDateTime", delegate + { + AssertExpression("#01/01/2001#", new DateTime(2001, 1, 1), typeof(DateTime)); + }); + + Run("ExpressionEvaluate_WithUnicodeAndNewlineEscapes_DoesUnescapeString", delegate + { + AssertExpression("'\\u0041\\n'", "A\n", typeof(string)); + }); + + Run("ExpressionEvaluate_WithIgnoreCaseBuiltInFunction_DoesResolveFunctionName", delegate + { + AssertObject(1m, typeof(decimal), new Expression("aBs(-1)", EvaluateOptions.IgnoreCase).Evaluate()); + }); + + Run("ExpressionEvaluate_WithDecimalDoubleMix_DoesReturnDecimal", delegate + { + Expression expression = new Expression("1.8 + Abs([var1])"); + expression.Parameters["var1"] = 9.2d; + + AssertObject(11.0m, typeof(decimal), expression.Evaluate()); + }); + } + + private static void RunNumbersCases() + { + Run("NumbersAdd_WithStringCommaDecimalUnderFrenchCulture_DoesParseUsingCurrentCulture", delegate + { + AssertOperation(Numbers.Add, "4,5", 1, 5.5m, typeof(decimal)); + }); + + Run("NumbersAdd_WithDoubleAndDecimal_DoesReturnDecimal", delegate + { + AssertOperation(Numbers.Add, 2.5d, 3.5m, 6.0m, typeof(decimal)); + }); + + Run("NumbersSoustract_WithByteAndByte_DoesPreserveNullFallthrough", delegate + { + AssertNull(Numbers.Soustract((byte)2, (byte)2)); + }); + + Run("NumbersSoustract_WithUnsignedOperands_DoesPreserveUncheckedWraparound", delegate + { + AssertOperation(Numbers.Soustract, (uint)6, (ulong)8, UInt64.MaxValue - 1, typeof(ulong)); + }); + + Run("NumbersDivide_WithUlongAndBoolean_DoesPreserveWrongOperatorMessage", delegate + { + AssertThrows( + delegate { Numbers.Divide((ulong)8, true); }, + typeof(InvalidOperationException), + "Operator '-'"); + }); + + Run("NumbersModulo_WithDecimalAndDouble_DoesPreserveDecimalMessageTypo", delegate + { + AssertThrows( + delegate { Numbers.Modulo(3.5m, 2.5d); }, + typeof(InvalidOperationException), + "decimal' and 'decimal"); + }); + + Run("NumbersMax_WithByteAndDecimal_DoesUseLeftOperandConversion", delegate + { + AssertOperation(Numbers.Max, (byte)2, 3.5m, (byte)4, typeof(byte)); + }); + + Run("NumbersMin_WithUshortAndDouble_DoesUseLeftOperandConversion", delegate + { + AssertOperation(Numbers.Min, (ushort)4, 2.5d, (ushort)2, typeof(ushort)); + }); + } + + private static void RunDomainCases() + { + Run("ValueExpression_WithFloatUnderFrenchCulture_DoesSerializeWithInvariantDecimalSeparator", delegate + { + AssertEqual("1.5", new ValueExpression(1.5f).ToString()); + }); + + Run("ValueExpression_WithUnsupportedObject_DoesThrowEvaluationException", delegate + { + AssertThrows( + delegate { new ValueExpression(new object()); }, + typeof(EvaluationException), + "This value could not be handled"); + }); + + Run("ExpressionEvaluate_WithManualAst_DoesUseSameArithmeticPipeline", delegate + { + Expression expression = new Expression( + new BinaryExpression( + BinaryExpressionType.Plus, + new ValueExpression(2), + new ValueExpression(3))); + + AssertObject(5, typeof(int), expression.Evaluate()); + }); + + Run("ValueExpression_WithDateTime_DoesInferDateTimeType", delegate + { + DateTime dateTime = new DateTime(2026, 6, 20); + ValueExpression expression = new ValueExpression(dateTime); + + AssertEqual(DomainValueType.DateTime, expression.Type); + AssertEqual(dateTime, expression.Value); + }); + } + + private static void RunCallbackCases() + { + Run("ExpressionEvaluate_WithLazyTernary_DoesNotResolveUnusedBranch", delegate + { + Expression expression = new Expression("if([flag], [value], [missing])"); + expression.Parameters["flag"] = true; + expression.Parameters["value"] = 42; + expression.EvaluateParameter += delegate(string name, ParameterArgs args) + { + if (name == "missing") + { + throw new InvalidOperationException("Unused branch was evaluated."); + } + }; + + AssertObject(42, typeof(int), expression.Evaluate()); + }); + + Run("ExpressionEvaluate_WithCustomFunction_DoesPreserveLazyParameterEvaluation", delegate + { + Expression expression = new Expression("twice([value] + 1)"); + expression.Parameters["value"] = 20; + expression.EvaluateFunction += delegate(string name, FunctionArgs args) + { + if (name == "twice") + { + args.Result = Convert.ToInt32(args.Parameters[0].Evaluate()) * 2; + } + }; + + AssertObject(42, typeof(int), expression.Evaluate()); + }); + } + + private static void AssertExpression(string expression, object expectedValue, Type expectedType) + { + AssertObject(expectedValue, expectedType, new Expression(expression).Evaluate()); + } + + private static void AssertOperation(BinaryOperation operation, object left, object right, object expectedValue, Type expectedType) + { + AssertObject(expectedValue, expectedType, operation(left, right)); + } + + private static void AssertObject(object expectedValue, Type expectedType, object actualValue) + { + if (actualValue == null) + { + throw new InvalidOperationException("Expected value <" + expectedValue + "> but got null."); + } + + if (actualValue.GetType() != expectedType) + { + throw new InvalidOperationException("Expected type <" + expectedType.FullName + "> but got <" + actualValue.GetType().FullName + ">."); + } + + AssertEqual(expectedValue, actualValue); + } + + private static void AssertNull(object actualValue) + { + if (actualValue != null) + { + throw new InvalidOperationException("Expected null but got <" + actualValue + ">."); + } + } + + private static void AssertEqual(object expectedValue, object actualValue) + { + if (!object.Equals(expectedValue, actualValue)) + { + throw new InvalidOperationException("Expected <" + expectedValue + "> but got <" + actualValue + ">."); + } + } + + private static void AssertThrows(Action action, Type expectedExceptionType, string expectedMessageFragment) + { + try + { + action(); + } + catch (Exception exception) + { + if (exception.GetType() != expectedExceptionType) + { + throw new InvalidOperationException("Expected exception <" + expectedExceptionType.FullName + "> but got <" + exception.GetType().FullName + ">.", exception); + } + + if (exception.Message.IndexOf(expectedMessageFragment, StringComparison.Ordinal) < 0) + { + throw new InvalidOperationException("Expected message <" + exception.Message + "> to contain <" + expectedMessageFragment + ">."); + } + + return; + } + + throw new InvalidOperationException("Expected exception <" + expectedExceptionType.FullName + ">."); + } + + private static void Run(string name, Action action) + { + try + { + action(); + _passed++; + Console.WriteLine("PASS " + name); + } + catch (Exception exception) + { + _failed++; + Console.Error.WriteLine("FAIL " + name); + Console.Error.WriteLine(exception); + } + } + } +} diff --git a/docs/modernization-release.md b/docs/modernization-release.md index 64650f6..4f74aed 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -17,15 +17,19 @@ After the earlier modernization PRs are applied, maintainers should be able to r ```sh dotnet restore Evaluant.Calculator/NCalc.csproj dotnet restore Evaluant.Calculator.Tests/NCalc.Tests.csproj +dotnet restore Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore +dotnet build Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --no-restore +dotnet run --project Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --framework net10.0 --no-build +mono Evaluant.Calculator.FrameworkCompatibility.Tests/bin/Release/net35/NCalc.FrameworkCompatibility.Tests.exe dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build --output artifacts/packages /p:ContinuousIntegrationBuild=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg ``` The pack command should emit both `.nupkg` and `.snupkg` files after the SDK-style project conversion. The current legacy project layout still targets .NET Framework 3.5, so these commands depend on the earlier SDK-style conversion PRs. -The GitHub CI workflow runs the same restore, build, test, and pack flow on Ubuntu, macOS, and Windows. It validates the library and modern test project directly because the full solution still contains the legacy `Evaluant.Calculator.Play` .NET Framework 3.5 sample project. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. +The GitHub CI workflow runs the same restore, build, test, and pack flow on Ubuntu, macOS, and Windows. A dedicated Ubuntu quality-gates job also runs `scripts/validate.sh`, including API compatibility, coverage, local-feed consumer smoke, and the compatibility harness against both `net35` under Mono and `netstandard2.0` under modern .NET. CI validates the library and modern test project directly because the full solution still contains the legacy `Evaluant.Calculator.Play` .NET Framework 3.5 sample project. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. ## Release Quality Gate diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 0f0c4dc..928e08a 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -13,6 +13,7 @@ Required gates: - A Cobertura coverage report is produced by `dotnet test`. - The package builds locally with `dotnet pack`. - Public API changes require explicit approval and a written compatibility note. +- Framework compatibility tests pass against both the `net35` asset and the `netstandard2.0` asset. - Consumer smoke tests pass from a local package feed for modern .NET. - Windows must run a .NET Framework consumer smoke before any public release that claims Framework compatibility. @@ -28,15 +29,21 @@ The script validates the production package and test project directly: 1. `dotnet restore Evaluant.Calculator/NCalc.csproj` 2. `dotnet restore Evaluant.Calculator.Tests/NCalc.Tests.csproj` -3. `dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore` -4. `dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore` -5. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` -6. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` -7. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package -8. A modern .NET consumer smoke from the packed local package +3. `dotnet restore Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj` +4. `dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore` +5. `dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore` +6. `dotnet build Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --no-restore` +7. Compatibility assertions against the `netstandard2.0` asset using `dotnet run` +8. Compatibility assertions against the `net35` asset using `mono` on Unix-like systems or the `.exe` directly on Windows +9. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` +10. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` +11. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package +12. A modern .NET consumer smoke from the packed local package Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. +The Framework compatibility harness intentionally uses a plain console runner rather than MSTest so the same assertions can execute on the `net35` build under Mono and the `netstandard2.0` build under modern .NET. It focuses on culture-sensitive parsing, date and Unicode parsing, decimal/float conversion, unsigned arithmetic quirks, exact legacy exception messages, lazy callbacks, and manual AST evaluation. + Local validation packs `NCalc` as `1.3.8-qualitygates` by default. This avoids NuGet reusing a globally cached `NCalc` `1.3.8` baseline package during consumer smoke tests. Override with `PACKAGE_VERSION=...` only when the replacement version is deliberate. The full legacy `NCalc.sln` still contains `Evaluant.Calculator.Play`, a .NET Framework 3.5 sample project. Solution-wide validation should be re-enabled after that sample project is either modernized, removed from the solution, or gated to Windows/Mono explicitly. diff --git a/scripts/validate.sh b/scripts/validate.sh index a1eb156..e0c6162 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -12,6 +12,7 @@ readonly API_COMPAT_BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" readonly CORE_PROJECT="${CORE_PROJECT:-Evaluant.Calculator/NCalc.csproj}" readonly TEST_PROJECT="${TEST_PROJECT:-Evaluant.Calculator.Tests/NCalc.Tests.csproj}" +readonly FRAMEWORK_COMPATIBILITY_PROJECT="${FRAMEWORK_COMPATIBILITY_PROJECT:-Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj}" main() { cd "${REPOSITORY_ROOT}" @@ -21,6 +22,7 @@ main() { restore_projects build_projects + run_framework_compatibility_tests run_tests_with_coverage pack_package run_api_compatibility_check @@ -58,12 +60,36 @@ restore_projects() { print_section "Restoring projects" dotnet restore "${CORE_PROJECT}" dotnet restore "${TEST_PROJECT}" + dotnet restore "${FRAMEWORK_COMPATIBILITY_PROJECT}" } build_projects() { print_section "Building projects" dotnet build "${CORE_PROJECT}" --configuration "${CONFIGURATION}" --no-restore dotnet build "${TEST_PROJECT}" --configuration "${CONFIGURATION}" --no-restore + dotnet build "${FRAMEWORK_COMPATIBILITY_PROJECT}" --configuration "${CONFIGURATION}" --no-restore +} + +run_framework_compatibility_tests() { + local net35_executable + + print_section "Running framework compatibility tests" + + dotnet run \ + --project "${FRAMEWORK_COMPATIBILITY_PROJECT}" \ + --configuration "${CONFIGURATION}" \ + --framework net10.0 \ + --no-build + + net35_executable="${REPOSITORY_ROOT}/Evaluant.Calculator.FrameworkCompatibility.Tests/bin/${CONFIGURATION}/net35/NCalc.FrameworkCompatibility.Tests.exe" + + if [[ "${OS:-}" == "Windows_NT" ]]; then + "${net35_executable}" + return + fi + + require_command mono + mono "${net35_executable}" } run_tests_with_coverage() { From a43f8bcbc68fe4b6b8a5c9709413dd43e71551c7 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 12:31:33 +0100 Subject: [PATCH 11/18] Harden major release package identity --- .github/workflows/ci.yml | 49 +-- .github/workflows/release.yml | 30 +- .gitignore | 6 + Evaluant.Calculator/ILRepack.targets | 25 ++ Evaluant.Calculator/NCalc.csproj | 7 +- .../Properties/AssemblyInfo.cs | 4 +- docs/modernization-release.md | 26 +- docs/quality-gates.md | 43 ++- scripts/consumer-smoke.sh | 13 + scripts/package-compatibility.sh | 357 ++++++++++++++++++ scripts/validate.sh | 40 +- 11 files changed, 563 insertions(+), 37 deletions(-) create mode 100644 Evaluant.Calculator/ILRepack.targets create mode 100755 scripts/package-compatibility.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0db1941..81b71c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ env: CORE_PROJECT: Evaluant.Calculator/NCalc.csproj TEST_PROJECT: Evaluant.Calculator.Tests/NCalc.Tests.csproj DOTNET_NOLOGO: true + NCALC_EXPECTED_PUBLIC_KEY_TOKEN: f59bbff0fd43f598 jobs: quality-gates: @@ -32,16 +33,37 @@ jobs: with: dotnet-version: 10.0.x - - name: Install Mono runtime + - name: Install Mono tooling run: | sudo apt-get update - sudo apt-get install --yes mono-runtime + sudo apt-get install --yes mono-devel mono-utils + + - name: Prepare strong-name key + shell: bash + env: + NCALC_STRONG_NAME_KEY_BASE64: ${{ secrets.NCALC_STRONG_NAME_KEY_BASE64 }} + run: | + set -euo pipefail + + key_path="$RUNNER_TEMP/NCalc.snk" + + if [ -n "${NCALC_STRONG_NAME_KEY_BASE64:-}" ]; then + printf '%s' "$NCALC_STRONG_NAME_KEY_BASE64" | base64 --decode > "$key_path" + else + sn -k "$key_path" + sn -p "$key_path" "$RUNNER_TEMP/NCalc.public.snk" >/dev/null + token="$(sn -tp "$RUNNER_TEMP/NCalc.public.snk" | awk '/Public Key Token/ { print $4 }')" + echo "NCALC_EXPECTED_PUBLIC_KEY_TOKEN=$token" >> "$GITHUB_ENV" + echo "Using an ephemeral strong-name key for non-release CI validation." + fi + + echo "NCalcStrongNameKeyFile=$key_path" >> "$GITHUB_ENV" - name: Run local validation gates run: scripts/validate.sh - build-test-pack: - name: Restore, build, test, and pack (${{ matrix.os }}) + build-test: + name: Restore, build, and test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -81,16 +103,6 @@ jobs: --logger "trx;LogFileName=test-results.trx" --results-directory TestResults - - name: Pack - run: > - dotnet pack ${{ env.CORE_PROJECT }} - --configuration ${{ env.CONFIGURATION }} - --no-build - --output artifacts/packages - /p:ContinuousIntegrationBuild=true - /p:IncludeSymbols=true - /p:SymbolPackageFormat=snupkg - - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -98,12 +110,3 @@ jobs: name: test-results-${{ matrix.os }} path: TestResults if-no-files-found: ignore - - - name: Upload NuGet package - uses: actions/upload-artifact@v4 - with: - name: nuget-package-${{ matrix.os }} - path: | - artifacts/packages/*.nupkg - artifacts/packages/*.snupkg - if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58eb34b..3c430f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ env: CORE_PROJECT: Evaluant.Calculator/NCalc.csproj TEST_PROJECT: Evaluant.Calculator.Tests/NCalc.Tests.csproj DOTNET_NOLOGO: true + NCALC_EXPECTED_PUBLIC_KEY_TOKEN: f59bbff0fd43f598 jobs: package: @@ -55,10 +56,35 @@ jobs: echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" - - name: Install Mono runtime + - name: Install Mono tooling run: | sudo apt-get update - sudo apt-get install --yes mono-runtime + sudo apt-get install --yes mono-devel mono-utils + + - name: Prepare release strong-name key + shell: bash + env: + NCALC_STRONG_NAME_KEY_BASE64: ${{ secrets.NCALC_STRONG_NAME_KEY_BASE64 }} + run: | + set -euo pipefail + + if [ -z "${NCALC_STRONG_NAME_KEY_BASE64:-}" ]; then + echo "NCALC_STRONG_NAME_KEY_BASE64 is required for release packaging." >&2 + exit 2 + fi + + key_path="$RUNNER_TEMP/NCalc.snk" + public_key_path="$RUNNER_TEMP/NCalc.public.snk" + printf '%s' "$NCALC_STRONG_NAME_KEY_BASE64" | base64 --decode > "$key_path" + sn -p "$key_path" "$public_key_path" >/dev/null + + actual_token="$(sn -tp "$public_key_path" | awk '/Public Key Token/ { print $4 }')" + if [ "$actual_token" != "$NCALC_EXPECTED_PUBLIC_KEY_TOKEN" ]; then + echo "Release signing key token mismatch: expected $NCALC_EXPECTED_PUBLIC_KEY_TOKEN, got $actual_token." >&2 + exit 1 + fi + + echo "NCalcStrongNameKeyFile=$key_path" >> "$GITHUB_ENV" - name: Run release validation gates run: scripts/validate.sh diff --git a/.gitignore b/.gitignore index 544e383..6276967 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,12 @@ _Package/ *.snupkg packages/ +# Private signing material +*.snk +*.pfx +*.p12 +*.pem + # Patch and merge leftovers *.patch *.orig diff --git a/Evaluant.Calculator/ILRepack.targets b/Evaluant.Calculator/ILRepack.targets new file mode 100644 index 0000000..143583d --- /dev/null +++ b/Evaluant.Calculator/ILRepack.targets @@ -0,0 +1,25 @@ + + + + $(NuGetPackageRoot)antlr3.runtime/3.5.1/lib/net20/Antlr3.Runtime.dll + $(NuGetPackageRoot)antlr3.runtime/3.5.1/lib/netstandard1.1/Antlr3.Runtime.dll + + + + + + + + + + + + + diff --git a/Evaluant.Calculator/NCalc.csproj b/Evaluant.Calculator/NCalc.csproj index 714a9e3..e5f7af0 100644 --- a/Evaluant.Calculator/NCalc.csproj +++ b/Evaluant.Calculator/NCalc.csproj @@ -4,15 +4,18 @@ NCalc NCalc false - 1.3.8 + 2.0.0 NCalc false Sebastien Ros NCalc is a mathematical expressions evaluator in .NET. + true + $(NCalcStrongNameKeyFile) - + + diff --git a/Evaluant.Calculator/Properties/AssemblyInfo.cs b/Evaluant.Calculator/Properties/AssemblyInfo.cs index 398a10f..50e37bc 100644 --- a/Evaluant.Calculator/Properties/AssemblyInfo.cs +++ b/Evaluant.Calculator/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // // You can specify all the values or you can default the Revision and Build Numbers // by using the '*' as shown below: -[assembly: AssemblyVersion("1.3.8.0")] -[assembly: AssemblyFileVersion("1.3.8.0")] +[assembly: AssemblyVersion("2.0.0.0")] +[assembly: AssemblyFileVersion("2.0.0.0")] diff --git a/docs/modernization-release.md b/docs/modernization-release.md index 4f74aed..a85eb2d 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -29,7 +29,7 @@ dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build The pack command should emit both `.nupkg` and `.snupkg` files after the SDK-style project conversion. The current legacy project layout still targets .NET Framework 3.5, so these commands depend on the earlier SDK-style conversion PRs. -The GitHub CI workflow runs the same restore, build, test, and pack flow on Ubuntu, macOS, and Windows. A dedicated Ubuntu quality-gates job also runs `scripts/validate.sh`, including API compatibility, coverage, local-feed consumer smoke, and the compatibility harness against both `net35` under Mono and `netstandard2.0` under modern .NET. CI validates the library and modern test project directly because the full solution still contains the legacy `Evaluant.Calculator.Play` .NET Framework 3.5 sample project. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. +The GitHub CI workflow runs restore, build, and test on Ubuntu, macOS, and Windows. A dedicated Ubuntu quality-gates job runs `scripts/validate.sh`, including signed package creation, package compatibility, API compatibility, coverage, local-feed consumer smoke, and the compatibility harness against both `net35` under Mono and `netstandard2.0` under modern .NET. CI validates the library and modern test project directly because the full solution still contains the legacy `Evaluant.Calculator.Play` .NET Framework 3.5 sample project. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. ## Release Quality Gate @@ -51,6 +51,28 @@ Packages are configured for deterministic builds, Source Link, repository metada No license metadata is declared here because this checkout does not contain a license file or existing license declaration. Add an explicit repository license before publishing a public package version. +## Strong-Name Signing + +The old `NCalc` `1.3.8` strong-name private key is unavailable. Its public key token was `973cde3f1cafed03`, so a newly signed modernization package cannot be binary-compatible for already-compiled strong-name consumers. This release line is therefore `2.0.0` or later, uses assembly version `2.0.0.0`, and uses the new public key token `f59bbff0fd43f598`. + +Do not commit private signing keys. Local production-token validation should use the ignored private key for token `f59bbff0fd43f598`: + +```sh +scripts/validate.sh +``` + +If the production key is stored elsewhere, set: + +```sh +NCalcStrongNameKeyFile=/secure/path/NCalc.snk scripts/validate.sh +``` + +For temporary non-release validation with a throwaway key, generate an ignored key and set `NCALC_EXPECTED_PUBLIC_KEY_TOKEN` to the generated token. Do not publish a package from a throwaway key. + +GitHub release packaging requires `NCALC_STRONG_NAME_KEY_BASE64`, a base64-encoded `.snk` stored as a repository secret. The release workflow decodes that secret to the runner temp directory and fails if the public key token does not match `f59bbff0fd43f598`. + +For non-release CI, the quality-gates workflow can generate an ephemeral key when the secret is unavailable. That still proves the package is signable, repacked, and source-compatible, while the release workflow remains the authority for the stable production token. + ## Deferred Package Validation And Restore Locking Package validation settings are intentionally not enabled in this lane yet. They should be added after the SDK-style project exists and a real package baseline/versioning policy is chosen; enabling package validation without a baseline here would either be untestable on this branch or risk noisy validation with no compatibility signal. @@ -66,4 +88,4 @@ The release workflow builds, tests, packs, uploads the `.nupkg` and `.snupkg` ar - a GitHub Release is published; or - the manual workflow is run with `publish` enabled. -Publishing requires a repository secret named `NUGET_API_KEY`. +Publishing requires repository secrets named `NUGET_API_KEY` and `NCALC_STRONG_NAME_KEY_BASE64`. diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 928e08a..4389b15 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -12,11 +12,25 @@ Required gates: - The expanded characterization tests pass. - A Cobertura coverage report is produced by `dotnet test`. - The package builds locally with `dotnet pack`. +- The package assemblies are strong-name signed. +- The package does not expose `Antlr3.Runtime` as a NuGet dependency; the ANTLR runtime surface remains embedded in `NCalc.dll`. - Public API changes require explicit approval and a written compatibility note. - Framework compatibility tests pass against both the `net35` asset and the `netstandard2.0` asset. - Consumer smoke tests pass from a local package feed for modern .NET. - Windows must run a .NET Framework consumer smoke before any public release that claims Framework compatibility. +## Strong-Name Key Policy + +The `NCalc` `1.3.8` package was signed with public key token `973cde3f1cafed03`. The private key for that token is not available, so the modernization release is a major-version package identity break even when source and runtime behavior are preserved. + +The new major-version key generated for this release line has public key token `f59bbff0fd43f598`. Private key material must never be committed. The repository ignores `.snk`, `.pfx`, `.p12`, and `.pem` files, and `scripts/validate.sh` expects one of these signing paths: + +- local production-token validation: the ignored private key for token `f59bbff0fd43f598` at `build/NCalc.snk`, or `NCalcStrongNameKeyFile=/secure/path/NCalc.snk`; +- local throwaway validation: any ignored `.snk`, plus `NCALC_EXPECTED_PUBLIC_KEY_TOKEN` set to that key's token; +- release CI: a repository secret named `NCALC_STRONG_NAME_KEY_BASE64`, containing the base64-encoded private `.snk`. + +Because the public key token changes, already-compiled .NET Framework consumers that reference `NCalc, PublicKeyToken=973cde3f1cafed03` must rebuild against the major-version package. Binding redirects cannot redirect one strong-name public key token to another. The package compatibility gate records this deliberately as a major-version identity break rather than pretending binary compatibility exists. + ## Local Validation After stacking on the core port and modern test project, run: @@ -37,14 +51,15 @@ The script validates the production package and test project directly: 8. Compatibility assertions against the `net35` asset using `mono` on Unix-like systems or the `.exe` directly on Windows 9. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` 10. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` -11. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package -12. A modern .NET consumer smoke from the packed local package +11. package compatibility validation for signing, embedded ANTLR, parser source compatibility, and the deliberate major-version identity break +12. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package +13. A modern .NET consumer smoke from the packed local package Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. The Framework compatibility harness intentionally uses a plain console runner rather than MSTest so the same assertions can execute on the `net35` build under Mono and the `netstandard2.0` build under modern .NET. It focuses on culture-sensitive parsing, date and Unicode parsing, decimal/float conversion, unsigned arithmetic quirks, exact legacy exception messages, lazy callbacks, and manual AST evaluation. -Local validation packs `NCalc` as `1.3.8-qualitygates` by default. This avoids NuGet reusing a globally cached `NCalc` `1.3.8` baseline package during consumer smoke tests. Override with `PACKAGE_VERSION=...` only when the replacement version is deliberate. +Local validation packs `NCalc` as `2.0.0-qualitygates` by default. This avoids NuGet reusing a globally cached `NCalc` `1.3.8` baseline package during consumer smoke tests and reflects the major-version package identity change. Override with `PACKAGE_VERSION=...` only when the replacement version is deliberate. The full legacy `NCalc.sln` still contains `Evaluant.Calculator.Play`, a .NET Framework 3.5 sample project. Solution-wide validation should be re-enabled after that sample project is either modernized, removed from the solution, or gated to Windows/Mono explicitly. @@ -73,7 +88,7 @@ dotnet pack Evaluant.Calculator/NCalc.csproj \ --no-build \ --output artifacts/packages/api-compat \ /p:ContinuousIntegrationBuild=true \ - /p:PackageVersion=1.3.8-qualitygates \ + /p:PackageVersion=2.0.0-qualitygates \ /p:EnablePackageValidation=true \ /p:PackageValidationBaselineVersion=1.3.8 \ /p:NoWarn=PKV006 @@ -87,12 +102,30 @@ If the public generated ANTLR parser surface makes package validation too noisy, - the new public signature, - why compatibility is still acceptable or why a breaking-change version is required. +## Package Compatibility + +SDK package validation checks public API compatibility, but it does not prove all package identity facts that matter for this modernization. The additional package gate is: + +```sh +scripts/package-compatibility.sh artifacts/packages 2.0.0-qualitygates +``` + +It verifies: + +- the baseline `NCalc` `1.3.8` package has token `973cde3f1cafed03`; +- the current package assemblies are strong-name signed with the configured expected token; +- the current package assemblies use assembly version `2.0.0.0`; +- `Antlr3.Runtime` is not a package dependency; +- the `net35` and `netstandard2.0` assemblies embed public `Antlr.Runtime.*`, `NCalcLexer`, `NCalcParser`, and parser return-scope types; +- a parser/lexer consumer can compile and run against the packed `net35` assembly; +- a consumer compiled against the legacy package references the old token, documenting why a new key is a major-version binary identity break. + ## Consumer Smoke Modern .NET smoke is automated: ```sh -scripts/consumer-smoke.sh artifacts/packages 1.3.8-qualitygates +scripts/consumer-smoke.sh artifacts/packages 2.0.0-qualitygates ``` Windows .NET Framework smoke is required before publishing. Minimum release checklist: diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh index 00889a8..0e094ef 100755 --- a/scripts/consumer-smoke.sh +++ b/scripts/consumer-smoke.sh @@ -71,6 +71,8 @@ add_local_package() { write_smoke_program() { cat >"${SMOKE_ROOT}/NCalc.ConsumerSmoke/Program.cs" <<'CSHARP' using System; +using Antlr.Runtime; +using Antlr.Runtime.Tree; using NCalc; object result = new Expression("2 * (3 + 5)").Evaluate(); @@ -80,6 +82,17 @@ if (!Equals(16, result)) throw new InvalidOperationException($"Unexpected NCalc result: {result}"); } +NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); +CommonTokenStream tokens = new CommonTokenStream(lexer); +NCalcParser parser = new NCalcParser(tokens); +NCalcParser.ncalcExpression_return parserResult = parser.ncalcExpression(); +CommonTree? tree = parserResult.Tree as CommonTree; + +if (tree is null) +{ + throw new InvalidOperationException("Expected parser to return a CommonTree."); +} + Console.WriteLine($"NCalc consumer smoke passed: {result}"); CSHARP } diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh new file mode 100755 index 0000000..f4bf5c4 --- /dev/null +++ b/scripts/package-compatibility.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash + +# Validates package-level compatibility facts that SDK API compatibility does not +# prove: strong-name identity, embedded ANTLR surface, NuGet dependencies, and +# the deliberate major-version binary identity break from the legacy key. + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly PACKAGE_SOURCE="${1:-${REPOSITORY_ROOT}/artifacts/packages}" +readonly PACKAGE_VERSION="${2:-${PACKAGE_VERSION:-}}" +readonly BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" +readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" +readonly EXPECTED_PUBLIC_KEY_TOKEN="${NCALC_EXPECTED_PUBLIC_KEY_TOKEN:-f59bbff0fd43f598}" +readonly EXPECTED_ASSEMBLY_VERSION="${NCALC_EXPECTED_ASSEMBLY_VERSION:-2.0.0.0}" +readonly LEGACY_PUBLIC_KEY_TOKEN="${NCALC_LEGACY_PUBLIC_KEY_TOKEN:-973cde3f1cafed03}" +readonly PACKAGE_IDENTITY_MODE="${NCALC_PACKAGE_IDENTITY_MODE:-major}" +readonly WORK_ROOT="${PACKAGE_COMPATIBILITY_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/ncalc-package-compatibility.XXXXXX")}" +readonly CURRENT_EXTRACT_ROOT="${WORK_ROOT}/current" +readonly BASELINE_EXTRACT_ROOT="${WORK_ROOT}/baseline" + +main() { + require_command dotnet + require_command monodis + require_command sn + require_command unzip + require_command mcs + require_command mono + require_identity_mode + + mkdir -p "${CURRENT_EXTRACT_ROOT}" "${BASELINE_EXTRACT_ROOT}" + + local current_package + local baseline_package + current_package="$(resolve_current_package)" + baseline_package="$(resolve_baseline_package)" + + extract_package "${current_package}" "${CURRENT_EXTRACT_ROOT}" + extract_package "${baseline_package}" "${BASELINE_EXTRACT_ROOT}" + + validate_baseline_identity + validate_current_identity + validate_current_package_shape + run_net35_parser_source_smoke + run_legacy_binary_identity_probe + + echo "Package compatibility checks passed for ${current_package}." +} + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Required command not found: ${command_name}" >&2 + exit 127 + fi +} + +require_identity_mode() { + case "${PACKAGE_IDENTITY_MODE}" in + major|legacy) + ;; + *) + echo "Unsupported NCALC_PACKAGE_IDENTITY_MODE: ${PACKAGE_IDENTITY_MODE}. Expected major or legacy." >&2 + exit 2 + ;; + esac +} + +resolve_current_package() { + if [[ -n "${PACKAGE_VERSION}" ]]; then + find "${PACKAGE_SOURCE}" -maxdepth 1 -name "NCalc.${PACKAGE_VERSION}.nupkg" -print -quit + return + fi + + find "${PACKAGE_SOURCE}" -maxdepth 1 -name 'NCalc.*.nupkg' ! -name '*.symbols.nupkg' -print -quit +} + +resolve_baseline_package() { + local package_path + package_path="${NUGET_PACKAGES:-${HOME}/.nuget/packages}/ncalc/${BASELINE_VERSION}/ncalc.${BASELINE_VERSION}.nupkg" + + if [[ -f "${package_path}" ]]; then + printf '%s\n' "${package_path}" + return + fi + + hydrate_baseline_package + + if [[ -f "${package_path}" ]]; then + printf '%s\n' "${package_path}" + return + fi + + echo "Could not hydrate NCalc ${BASELINE_VERSION} baseline package at ${package_path}." >&2 + exit 2 +} + +hydrate_baseline_package() { + local baseline_restore_root + baseline_restore_root="${WORK_ROOT}/baseline-restore" + mkdir -p "${baseline_restore_root}" + + cat >"${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" < + + net35 + + + + + +XML + + dotnet restore "${baseline_restore_root}/NCalc.ApiCompatBaseline.csproj" \ + --source "${NUGET_PACKAGE_SOURCE}" \ + >/dev/null +} + +extract_package() { + local package_path="$1" + local extract_root="$2" + + if [[ -z "${package_path}" || ! -f "${package_path}" ]]; then + echo "Package not found: ${package_path:-}" >&2 + exit 2 + fi + + rm -rf "${extract_root}" + mkdir -p "${extract_root}" + unzip -q "${package_path}" -d "${extract_root}" +} + +validate_baseline_identity() { + local baseline_assembly + baseline_assembly="${BASELINE_EXTRACT_ROOT}/lib/NCalc.dll" + + require_file "${baseline_assembly}" + assert_public_key_token "${baseline_assembly}" "${LEGACY_PUBLIC_KEY_TOKEN}" "legacy baseline" +} + +validate_current_identity() { + local expected_token + expected_token="${EXPECTED_PUBLIC_KEY_TOKEN}" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "legacy" ]]; then + expected_token="${LEGACY_PUBLIC_KEY_TOKEN}" + fi + + while IFS= read -r assembly_path; do + assert_strong_named "${assembly_path}" + assert_public_key_token "${assembly_path}" "${expected_token}" "current package" + assert_assembly_version "${assembly_path}" "${EXPECTED_ASSEMBLY_VERSION}" + done < <(current_assemblies) +} + +validate_current_package_shape() { + local nuspec_path + nuspec_path="$(find "${CURRENT_EXTRACT_ROOT}" -maxdepth 1 -name '*.nuspec' -print -quit)" + require_file "${nuspec_path}" + + if grep -q 'Antlr3.Runtime' "${nuspec_path}"; then + echo "Current package must not expose Antlr3.Runtime as a NuGet dependency." >&2 + exit 1 + fi + + if find "${CURRENT_EXTRACT_ROOT}" -name 'Antlr3.Runtime.dll' -print -quit | grep -q .; then + echo "Current package must not include Antlr3.Runtime.dll as a separate asset." >&2 + exit 1 + fi + + while IFS= read -r assembly_path; do + assert_no_antlr_assembly_reference "${assembly_path}" + assert_embedded_type "${assembly_path}" "NCalcLexer" + assert_embedded_type "${assembly_path}" "NCalcParser" + assert_embedded_type "${assembly_path}" "NCalcParser/ncalcExpression_return" + assert_embedded_type "${assembly_path}" "Antlr.Runtime.IAstRuleReturnScope" + assert_embedded_type "${assembly_path}" "Antlr.Runtime.Tree.CommonTree" + done < <(current_assemblies) +} + +current_assemblies() { + printf '%s\n' \ + "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" \ + "${CURRENT_EXTRACT_ROOT}/lib/netstandard2.0/NCalc.dll" +} + +require_file() { + local file_path="$1" + + if [[ ! -f "${file_path}" ]]; then + echo "Required file not found: ${file_path}" >&2 + exit 2 + fi +} + +assert_strong_named() { + local assembly_path="$1" + + require_file "${assembly_path}" + + if ! sn -vf "${assembly_path}" >/dev/null; then + echo "Assembly is not strong-named: ${assembly_path}" >&2 + exit 1 + fi +} + +assert_public_key_token() { + local assembly_path="$1" + local expected_token="$2" + local label="$3" + local actual_token + + actual_token="$(sn -T "${assembly_path}" | awk '/Public Key Token/ { print $4 }')" + + if [[ "${actual_token}" != "${expected_token}" ]]; then + echo "${label} public key token mismatch for ${assembly_path}: expected ${expected_token}, got ${actual_token:-}." >&2 + exit 1 + fi +} + +assert_assembly_version() { + local assembly_path="$1" + local expected_version="$2" + local actual_version + + actual_version="$(monodis --assembly "${assembly_path}" | awk '/^Version:/ { print $2; exit }')" + + if [[ "${actual_version}" != "${expected_version}" ]]; then + echo "Assembly version mismatch for ${assembly_path}: expected ${expected_version}, got ${actual_version:-}." >&2 + exit 1 + fi +} + +assert_no_antlr_assembly_reference() { + local assembly_path="$1" + + if monodis --assemblyref "${assembly_path}" | grep -q 'Antlr3.Runtime'; then + echo "Assembly must embed ANTLR instead of referencing Antlr3.Runtime: ${assembly_path}" >&2 + exit 1 + fi +} + +assert_embedded_type() { + local assembly_path="$1" + local type_name="$2" + + if ! monodis --typedef "${assembly_path}" | grep -q "${type_name}"; then + echo "Expected type ${type_name} in ${assembly_path}." >&2 + exit 1 + fi +} + +assert_assembly_reference_token() { + local assembly_path="$1" + local reference_name="$2" + local expected_token="$3" + local reference_block + + reference_block="$( + monodis --assemblyref "${assembly_path}" | + awk -v reference_name="Name=${reference_name}" ' + /^[0-9]+:/ { in_reference = 0 } + index($0, reference_name) { in_reference = 1 } + in_reference { print } + ' + )" + + if ! printf '%s' "${reference_block}" | tr -d '[:space:]' | grep -qi "${expected_token}"; then + echo "Expected ${assembly_path} to reference ${reference_name} with public key token ${expected_token}." >&2 + exit 1 + fi +} + +run_net35_parser_source_smoke() { + local smoke_root + smoke_root="${WORK_ROOT}/net35-parser-source-smoke" + mkdir -p "${smoke_root}" + + cp "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" "${smoke_root}/NCalc.dll" + write_parser_smoke_program "${smoke_root}/ParserSmoke.cs" + + mcs \ + -sdk:4 \ + -r:"${smoke_root}/NCalc.dll" \ + -out:"${smoke_root}/ParserSmoke.exe" \ + "${smoke_root}/ParserSmoke.cs" \ + >/dev/null + + mono "${smoke_root}/ParserSmoke.exe" >/dev/null +} + +run_legacy_binary_identity_probe() { + local smoke_root + smoke_root="${WORK_ROOT}/legacy-binary-identity-probe" + mkdir -p "${smoke_root}" + + cp "${BASELINE_EXTRACT_ROOT}/lib/NCalc.dll" "${smoke_root}/NCalc.dll" + write_parser_smoke_program "${smoke_root}/ParserSmoke.cs" + + mcs \ + -sdk:4 \ + -r:"${smoke_root}/NCalc.dll" \ + -out:"${smoke_root}/ParserSmoke.exe" \ + "${smoke_root}/ParserSmoke.cs" \ + >/dev/null + + cp "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" "${smoke_root}/NCalc.dll" + assert_assembly_reference_token "${smoke_root}/ParserSmoke.exe" "NCalc" "${LEGACY_PUBLIC_KEY_TOKEN}" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "legacy" ]]; then + mono "${smoke_root}/ParserSmoke.exe" >/dev/null + return + fi + + echo "Legacy binary probe references ${LEGACY_PUBLIC_KEY_TOKEN}; current package token ${EXPECTED_PUBLIC_KEY_TOKEN} is a deliberate major-version identity break." +} + +write_parser_smoke_program() { + local program_path="$1" + + cat >"${program_path}" <<'CSHARP' +using System; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using NCalc; + +internal static class ParserSmoke +{ + private static int Main() + { + object expressionResult = new Expression("2 * (3 + 5)").Evaluate(); + if (!object.Equals(16, expressionResult)) + { + throw new InvalidOperationException("Unexpected expression result: " + expressionResult); + } + + NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); + CommonTokenStream tokens = new CommonTokenStream(lexer); + NCalcParser parser = new NCalcParser(tokens); + NCalcParser.ncalcExpression_return parserResult = parser.ncalcExpression(); + CommonTree tree = parserResult.Tree as CommonTree; + + if (tree == null) + { + throw new InvalidOperationException("Expected parser to return a CommonTree."); + } + + Console.WriteLine("Parser smoke passed."); + return 0; + } +} +CSHARP +} + +main "$@" diff --git a/scripts/validate.sh b/scripts/validate.sh index e0c6162..5b06624 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -7,9 +7,10 @@ readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" readonly CONFIGURATION="${CONFIGURATION:-Release}" readonly COVERAGE_RESULTS_DIR="${COVERAGE_RESULTS_DIR:-${REPOSITORY_ROOT}/artifacts/coverage}" readonly PACKAGE_OUTPUT_DIR="${PACKAGE_OUTPUT_DIR:-${REPOSITORY_ROOT}/artifacts/packages}" -readonly PACKAGE_VERSION="${PACKAGE_VERSION:-1.3.8-qualitygates}" +readonly PACKAGE_VERSION="${PACKAGE_VERSION:-2.0.0-qualitygates}" readonly API_COMPAT_BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" +readonly DEFAULT_LOCAL_STRONG_NAME_KEY="${REPOSITORY_ROOT}/build/NCalc.snk" readonly CORE_PROJECT="${CORE_PROJECT:-Evaluant.Calculator/NCalc.csproj}" readonly TEST_PROJECT="${TEST_PROJECT:-Evaluant.Calculator.Tests/NCalc.Tests.csproj}" readonly FRAMEWORK_COMPATIBILITY_PROJECT="${FRAMEWORK_COMPATIBILITY_PROJECT:-Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj}" @@ -19,12 +20,14 @@ main() { require_command dotnet require_sdk_style_core_project + prepare_strong_name_key restore_projects build_projects run_framework_compatibility_tests run_tests_with_coverage pack_package + run_package_compatibility_check run_api_compatibility_check run_modern_consumer_smoke @@ -56,6 +59,35 @@ MESSAGE echo "SDK-style core project detected." } +prepare_strong_name_key() { + print_section "Checking strong-name signing key" + + if [[ -z "${NCalcStrongNameKeyFile:-}" && -f "${DEFAULT_LOCAL_STRONG_NAME_KEY}" ]]; then + export NCalcStrongNameKeyFile="${DEFAULT_LOCAL_STRONG_NAME_KEY}" + fi + + if [[ -z "${NCalcStrongNameKeyFile:-}" ]]; then + cat >&2 <&2 + exit 2 + fi + + echo "Using private strong-name key from ${NCalcStrongNameKeyFile}." +} + restore_projects() { print_section "Restoring projects" dotnet restore "${CORE_PROJECT}" @@ -142,6 +174,12 @@ run_api_compatibility_check() { /p:NoWarn=PKV006 } +run_package_compatibility_check() { + print_section "Running package compatibility checks" + + "${SCRIPT_DIR}/package-compatibility.sh" "${PACKAGE_OUTPUT_DIR}" "${PACKAGE_VERSION}" +} + hydrate_api_compatibility_baseline() { local baseline_restore_root From c6c02608fd29b2f4e2c7fb0a02badd2ba5220e5b Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 12:42:54 +0100 Subject: [PATCH 12/18] Enforce release quality gates --- .github/workflows/release.yml | 54 +++++++++- Directory.Build.props | 1 + LICENSE | 21 ++++ docs/modernization-release.md | 4 +- docs/quality-gates.md | 26 +++-- scripts/package-compatibility.sh | 11 ++ scripts/validate.sh | 37 +++++++ scripts/windows-framework-consumer-smoke.ps1 | 106 +++++++++++++++++++ 8 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 LICENSE create mode 100644 scripts/windows-framework-consumer-smoke.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c430f3..150dec6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,8 +28,10 @@ env: jobs: package: - name: Build, test, pack, and optionally publish + name: Build, test, and pack runs-on: ubuntu-latest + outputs: + package-version: ${{ steps.resolve-package-version.outputs.package-version }} steps: - name: Check out repository @@ -41,6 +43,7 @@ jobs: dotnet-version: 10.0.x - name: Resolve package version + id: resolve-package-version shell: bash run: | if [ "$GITHUB_EVENT_NAME" = "release" ]; then @@ -55,6 +58,7 @@ jobs: fi echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" + echo "package-version=$package_version" >> "$GITHUB_OUTPUT" - name: Install Mono tooling run: | @@ -92,12 +96,58 @@ jobs: - name: Upload NuGet package uses: actions/upload-artifact@v4 with: - name: ncalc-${{ env.PACKAGE_VERSION }} + name: ncalc-${{ steps.resolve-package-version.outputs.package-version }} path: | artifacts/packages/*.nupkg artifacts/packages/*.snupkg if-no-files-found: error + windows-framework-smoke: + name: Windows .NET Framework package smoke + needs: package + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download NuGet package + uses: actions/download-artifact@v4 + with: + name: ncalc-${{ needs.package.outputs.package-version }} + path: artifacts/packages + + - name: Run .NET Framework consumer smoke + shell: pwsh + run: > + scripts/windows-framework-consumer-smoke.ps1 + -PackageSource artifacts/packages + -PackageVersion "${{ needs.package.outputs.package-version }}" + + publish: + name: Publish to NuGet.org + needs: + - package + - windows-framework-smoke + runs-on: ubuntu-latest + + steps: + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download NuGet package + uses: actions/download-artifact@v4 + with: + name: ncalc-${{ needs.package.outputs.package-version }} + path: artifacts/packages + - name: Publish to NuGet.org shell: bash run: | diff --git a/Directory.Build.props b/Directory.Build.props index abe6085..d53f9c4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,6 +12,7 @@ Mathematical expressions evaluator for .NET. ncalc;expression;expressions;calculator;formula;evaluator https://github.com/sheetsync/NCalc + MIT https://github.com/sheetsync/NCalc.git git README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d15b146 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2011 Sebastien Ros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/modernization-release.md b/docs/modernization-release.md index a85eb2d..1f29dec 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -49,7 +49,7 @@ This lane owns shared release metadata in `Directory.Build.props` so the SDK-sty Packages are configured for deterministic builds, Source Link, repository metadata, and `.snupkg` symbol output once the core project is SDK-style. -No license metadata is declared here because this checkout does not contain a license file or existing license declaration. Add an explicit repository license before publishing a public package version. +The package declares the MIT license expression and the repository contains a matching `LICENSE` file. This follows the legacy NCalc package's CodePlex license link and the current public NCalc project lineage. ## Strong-Name Signing @@ -83,7 +83,7 @@ Solution-wide validation should be restored after the sample application is mode ## GitHub Release Flow -The release workflow builds, tests, packs, uploads the `.nupkg` and `.snupkg` artifacts, and publishes them to NuGet.org when either: +The release workflow builds, tests, packs, uploads the `.nupkg` and `.snupkg` artifacts, runs a Windows .NET Framework package consumer smoke, and publishes them to NuGet.org when either: - a GitHub Release is published; or - the manual workflow is run with `publish` enabled. diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 4389b15..0f50198 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -13,6 +13,7 @@ Required gates: - A Cobertura coverage report is produced by `dotnet test`. - The package builds locally with `dotnet pack`. - The package assemblies are strong-name signed. +- The package declares the MIT NuGet license expression. - The package does not expose `Antlr3.Runtime` as a NuGet dependency; the ANTLR runtime surface remains embedded in `NCalc.dll`. - Public API changes require explicit approval and a written compatibility note. - Framework compatibility tests pass against both the `net35` asset and the `netstandard2.0` asset. @@ -50,12 +51,13 @@ The script validates the production package and test project directly: 7. Compatibility assertions against the `netstandard2.0` asset using `dotnet run` 8. Compatibility assertions against the `net35` asset using `mono` on Unix-like systems or the `.exe` directly on Windows 9. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` -10. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` -11. package compatibility validation for signing, embedded ANTLR, parser source compatibility, and the deliberate major-version identity break -12. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package -13. A modern .NET consumer smoke from the packed local package +10. coverage threshold enforcement, currently line coverage >= 93% and branch coverage >= 91% +11. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` +12. package compatibility validation for signing, license metadata, embedded ANTLR, parser source compatibility, and the deliberate major-version identity break +13. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package +14. A modern .NET consumer smoke from the packed local package -Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. +Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. `scripts/validate.sh` fails if line coverage falls below `93%` or branch coverage falls below `91%`. These thresholds sit below the current measured coverage so they prevent accidental erosion while leaving a small margin for harmless branch-shape changes. The Framework compatibility harness intentionally uses a plain console runner rather than MSTest so the same assertions can execute on the `net35` build under Mono and the `netstandard2.0` build under modern .NET. It focuses on culture-sensitive parsing, date and Unicode parsing, decimal/float conversion, unsigned arithmetic quirks, exact legacy exception messages, lazy callbacks, and manual AST evaluation. @@ -115,6 +117,7 @@ It verifies: - the baseline `NCalc` `1.3.8` package has token `973cde3f1cafed03`; - the current package assemblies are strong-name signed with the configured expected token; - the current package assemblies use assembly version `2.0.0.0`; +- the current package declares license expression `MIT`; - `Antlr3.Runtime` is not a package dependency; - the `net35` and `netstandard2.0` assemblies embed public `Antlr.Runtime.*`, `NCalcLexer`, `NCalcParser`, and parser return-scope types; - a parser/lexer consumer can compile and run against the packed `net35` assembly; @@ -128,11 +131,12 @@ Modern .NET smoke is automated: scripts/consumer-smoke.sh artifacts/packages 2.0.0-qualitygates ``` -Windows .NET Framework smoke is required before publishing. Minimum release checklist: +Windows .NET Framework smoke is automated in the release workflow: -1. Pack the branch locally. -2. Create a .NET Framework consumer project on Windows. -3. Restore `NCalc` from the local package feed. -4. Build and run an expression smoke such as `2 * (3 + 5) == 16`. +```powershell +scripts/windows-framework-consumer-smoke.ps1 ` + -PackageSource artifacts/packages ` + -PackageVersion 2.0.0-qualitygates +``` -This Windows gate is intentionally documented rather than scripted here because this macOS worktree cannot run a .NET Framework executable. +The script creates a temporary `net48` console project, restores `NCalc` from the local package feed, builds it, then executes expression and parser/lexer assertions against the package asset. NuGet publishing depends on this Windows job. diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index f4bf5c4..f850cbc 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -14,6 +14,7 @@ readonly BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" readonly EXPECTED_PUBLIC_KEY_TOKEN="${NCALC_EXPECTED_PUBLIC_KEY_TOKEN:-f59bbff0fd43f598}" readonly EXPECTED_ASSEMBLY_VERSION="${NCALC_EXPECTED_ASSEMBLY_VERSION:-2.0.0.0}" +readonly EXPECTED_LICENSE_EXPRESSION="${NCALC_EXPECTED_LICENSE_EXPRESSION:-MIT}" readonly LEGACY_PUBLIC_KEY_TOKEN="${NCALC_LEGACY_PUBLIC_KEY_TOKEN:-973cde3f1cafed03}" readonly PACKAGE_IDENTITY_MODE="${NCALC_PACKAGE_IDENTITY_MODE:-major}" readonly WORK_ROOT="${PACKAGE_COMPATIBILITY_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/ncalc-package-compatibility.XXXXXX")}" @@ -159,6 +160,7 @@ validate_current_package_shape() { local nuspec_path nuspec_path="$(find "${CURRENT_EXTRACT_ROOT}" -maxdepth 1 -name '*.nuspec' -print -quit)" require_file "${nuspec_path}" + assert_license_metadata "${nuspec_path}" if grep -q 'Antlr3.Runtime' "${nuspec_path}"; then echo "Current package must not expose Antlr3.Runtime as a NuGet dependency." >&2 @@ -180,6 +182,15 @@ validate_current_package_shape() { done < <(current_assemblies) } +assert_license_metadata() { + local nuspec_path="$1" + + if ! grep -Eq "]*type=\"expression\"[^>]*>${EXPECTED_LICENSE_EXPRESSION}" "${nuspec_path}"; then + echo "Expected NuGet license expression ${EXPECTED_LICENSE_EXPRESSION} in ${nuspec_path}." >&2 + exit 1 + fi +} + current_assemblies() { printf '%s\n' \ "${CURRENT_EXTRACT_ROOT}/lib/net35/NCalc.dll" \ diff --git a/scripts/validate.sh b/scripts/validate.sh index 5b06624..ca2af36 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -10,6 +10,8 @@ readonly PACKAGE_OUTPUT_DIR="${PACKAGE_OUTPUT_DIR:-${REPOSITORY_ROOT}/artifacts/ readonly PACKAGE_VERSION="${PACKAGE_VERSION:-2.0.0-qualitygates}" readonly API_COMPAT_BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" +readonly MINIMUM_LINE_COVERAGE="${MINIMUM_LINE_COVERAGE:-0.93}" +readonly MINIMUM_BRANCH_COVERAGE="${MINIMUM_BRANCH_COVERAGE:-0.91}" readonly DEFAULT_LOCAL_STRONG_NAME_KEY="${REPOSITORY_ROOT}/build/NCalc.snk" readonly CORE_PROJECT="${CORE_PROJECT:-Evaluant.Calculator/NCalc.csproj}" readonly TEST_PROJECT="${TEST_PROJECT:-Evaluant.Calculator.Tests/NCalc.Tests.csproj}" @@ -19,6 +21,7 @@ main() { cd "${REPOSITORY_ROOT}" require_command dotnet + require_command python3 require_sdk_style_core_project prepare_strong_name_key @@ -138,9 +141,43 @@ run_tests_with_coverage() { -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura find "${COVERAGE_RESULTS_DIR}" -name 'coverage.cobertura.xml' -print -quit | grep -q . + + local coverage_report + coverage_report="$(find "${COVERAGE_RESULTS_DIR}" -name 'coverage.cobertura.xml' -print -quit)" + enforce_coverage_thresholds "${coverage_report}" + echo "Coverage report produced under ${COVERAGE_RESULTS_DIR}." } +enforce_coverage_thresholds() { + local coverage_report="$1" + + python3 - "${coverage_report}" "${MINIMUM_LINE_COVERAGE}" "${MINIMUM_BRANCH_COVERAGE}" <<'PYTHON' +import sys +import xml.etree.ElementTree as ET + +coverage_report, minimum_line_coverage, minimum_branch_coverage = sys.argv[1:4] +minimum_line_coverage = float(minimum_line_coverage) +minimum_branch_coverage = float(minimum_branch_coverage) + +root = ET.parse(coverage_report).getroot() +line_rate = float(root.attrib["line-rate"]) +branch_rate = float(root.attrib["branch-rate"]) + +print( + "Coverage: line {line:.2%} (minimum {minimum_line:.2%}), branch {branch:.2%} (minimum {minimum_branch:.2%}).".format( + line=line_rate, + minimum_line=minimum_line_coverage, + branch=branch_rate, + minimum_branch=minimum_branch_coverage, + ) +) + +if line_rate < minimum_line_coverage or branch_rate < minimum_branch_coverage: + raise SystemExit(1) +PYTHON +} + pack_package() { print_section "Packing package" rm -rf "${PACKAGE_OUTPUT_DIR}" diff --git a/scripts/windows-framework-consumer-smoke.ps1 b/scripts/windows-framework-consumer-smoke.ps1 new file mode 100644 index 0000000..4a06fb1 --- /dev/null +++ b/scripts/windows-framework-consumer-smoke.ps1 @@ -0,0 +1,106 @@ +param( + [Parameter(Mandatory = $true)] + [string] $PackageSource, + + [Parameter(Mandatory = $true)] + [string] $PackageVersion, + + [string] $SmokeRoot = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "ncalc-framework-consumer-smoke-" + [System.Guid]::NewGuid().ToString("N")) +) + +$ErrorActionPreference = "Stop" + +function Require-Command { + param([Parameter(Mandatory = $true)][string] $Name) + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "Required command not found: $Name" + } +} + +function Write-SmokeProject { + param([Parameter(Mandatory = $true)][string] $ProjectDirectory) + + New-Item -ItemType Directory -Force -Path $ProjectDirectory | Out-Null + + @" + + + Exe + net48 + NCalc.FrameworkConsumerSmoke + NCalc.FrameworkConsumerSmoke + disable + disable + latest + + + + + + +"@ | Set-Content -NoNewline -Encoding UTF8 -Path (Join-Path $ProjectDirectory "NCalc.FrameworkConsumerSmoke.csproj") + + @" +using System; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using NCalc; + +namespace NCalc.FrameworkConsumerSmoke +{ + internal static class Program + { + private static int Main() + { + object result = new Expression("2 * (3 + 5)").Evaluate(); + if (!object.Equals(16, result)) + { + throw new InvalidOperationException("Unexpected expression result: " + result); + } + + NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); + CommonTokenStream tokens = new CommonTokenStream(lexer); + NCalcParser parser = new NCalcParser(tokens); + NCalcParser.ncalcExpression_return parserResult = parser.ncalcExpression(); + CommonTree tree = parserResult.Tree as CommonTree; + + if (tree == null) + { + throw new InvalidOperationException("Expected parser to return a CommonTree."); + } + + Console.WriteLine("NCalc .NET Framework consumer smoke passed: " + result); + return 0; + } + } +} +"@ | Set-Content -NoNewline -Encoding UTF8 -Path (Join-Path $ProjectDirectory "Program.cs") +} + +function Invoke-Smoke { + Require-Command "dotnet" + + $package = Get-ChildItem -Path $PackageSource -Filter "NCalc.$PackageVersion.nupkg" -File | Select-Object -First 1 + if ($null -eq $package) { + throw "NCalc $PackageVersion package not found in $PackageSource" + } + + $projectDirectory = Join-Path $SmokeRoot "NCalc.FrameworkConsumerSmoke" + $packagesDirectory = Join-Path $SmokeRoot "packages" + Write-SmokeProject -ProjectDirectory $projectDirectory + + $projectPath = Join-Path $projectDirectory "NCalc.FrameworkConsumerSmoke.csproj" + dotnet restore $projectPath --source $PackageSource --source "https://api.nuget.org/v3/index.json" /p:RestorePackagesPath=$packagesDirectory + dotnet build $projectPath --configuration Release --no-restore + + $executablePath = Join-Path $projectDirectory "bin/Release/net48/NCalc.FrameworkConsumerSmoke.exe" + if (!(Test-Path $executablePath)) { + throw "Expected smoke executable not found: $executablePath" + } + + & $executablePath +} + +Invoke-Smoke From e4a00a836694e05bbeed7aebab4a69627f7dffde Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 13:06:18 +0100 Subject: [PATCH 13/18] Harden release validation after review --- .github/workflows/release.yml | 11 ++ Evaluant.Calculator.Play/NCalc.Play.csproj | 110 +++--------------- Evaluant.Calculator.Play/Program.cs | 62 +++++----- .../Properties/AssemblyInfo.cs | 33 ------ .../BuiltInFunctionTests.cs | 2 +- .../EvaluationOptionTests.cs | 8 +- Evaluant.Calculator.Tests/Fixtures.cs | 22 ++-- Evaluant.Calculator.Tests/NumericTestCase.cs | 5 +- Evaluant.Calculator.Tests/TestSupport.cs | 5 +- NCalc.sln | 10 +- README.md | 95 ++++++++++++++- docs/modernization-release.md | 17 +-- docs/quality-gates.md | 26 +++-- scripts/consumer-smoke.sh | 13 ++- scripts/package-compatibility.sh | 48 +++++++- scripts/validate.sh | 74 +++++++++++- 16 files changed, 326 insertions(+), 215 deletions(-) delete mode 100644 Evaluant.Calculator.Play/Properties/AssemblyInfo.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 150dec6..5a6ea37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,17 @@ jobs: exit 1 fi + if [[ ! "$package_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Unsupported package version format: $package_version" >&2 + exit 2 + fi + + package_major_version="${BASH_REMATCH[1]}" + if [ "$package_major_version" -lt 2 ]; then + echo "NCalc uses a new strong-name key, so release package versions must be 2.0.0 or later." >&2 + exit 1 + fi + echo "PACKAGE_VERSION=$package_version" >> "$GITHUB_ENV" echo "package-version=$package_version" >> "$GITHUB_OUTPUT" diff --git a/Evaluant.Calculator.Play/NCalc.Play.csproj b/Evaluant.Calculator.Play/NCalc.Play.csproj index 4823ad6..b623a3b 100644 --- a/Evaluant.Calculator.Play/NCalc.Play.csproj +++ b/Evaluant.Calculator.Play/NCalc.Play.csproj @@ -1,95 +1,15 @@ - - - - Debug - AnyCPU - 9.0.30729 - 2.0 - {4183699B-5B7E-4294-9807-9709BAD86477} - Exe - Properties - NCalc.Play - Play - - - 3.5 - - - v3.5 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - AllRules.ruleset - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - AllRules.ruleset - - - - - - - - - - - - - {5F014003-50D8-49E0-8AFE-91D38DCCC97C} - NCalc - - - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - true - - - False - Windows Installer 3.1 - true - - - - - \ No newline at end of file + + + Exe + net10.0 + NCalc.Play + NCalc.Play + disable + disable + latest + + + + + + diff --git a/Evaluant.Calculator.Play/Program.cs b/Evaluant.Calculator.Play/Program.cs index a4a847f..4441fd4 100644 --- a/Evaluant.Calculator.Play/Program.cs +++ b/Evaluant.Calculator.Play/Program.cs @@ -1,33 +1,29 @@ -using System; - -namespace NCalc.Play -{ - /// - /// Summary description for Program. - /// - public class Program - { - public static void Main(string[] args) - { - - - var expressions = new[] - { - "2 * (3 + 5)", - "2 * (2*(2*(2+1)))", - "10 % 3", - "false || not (false and true)", - "3 > 2 and 1 <= (3-2)", - "3 % 2 != 10 % 3", - "if( [age] >= 18, 'majeur', 'mineur')", - "CalculateBenefits([user]) * [Taxes]" - }; - - foreach (string expression in expressions) - Console.WriteLine("{0} = {1}", - expression, - new Expression(expression).Evaluate()); - - } - } -} +using System; + +namespace NCalc.Play +{ + public static class Program + { + public static void Main() + { + string[] expressions = + { + "2 * (3 + 5)", + "2 * (2*(2*(2+1)))", + "10 % 3", + "false || not (false and true)", + "3 > 2 and 1 <= (3-2)", + "3 % 2 != 10 % 3", + "if([age] >= 18, 'adult', 'minor')", + }; + + foreach (string expressionText in expressions) + { + Expression expression = new Expression(expressionText); + expression.Parameters["age"] = 42; + + Console.WriteLine("{0} = {1}", expressionText, expression.Evaluate()); + } + } + } +} diff --git a/Evaluant.Calculator.Play/Properties/AssemblyInfo.cs b/Evaluant.Calculator.Play/Properties/AssemblyInfo.cs deleted file mode 100644 index b00a2e2..0000000 --- a/Evaluant.Calculator.Play/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Evaluant.Calculator.Play")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Evaluant.Calculator.Play")] -[assembly: AssemblyCopyright("Copyright © 2007")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("a49c6a87-4568-4ec3-a162-374db1fccd80")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs b/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs index 2984772..61cae6f 100644 --- a/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs +++ b/Evaluant.Calculator.Tests/BuiltInFunctionTests.cs @@ -124,7 +124,7 @@ public void IfFunction_WithUnusedBranchContainingError_DoesNotEvaluateUnusedBran [TestMethod] public void InFunction_WithMatchBeforeInvalidExpression_DoesNotEvaluateRemainingArguments() { - Assert.AreEqual(true, TestSupport.Evaluate("in(4, 1, 2, 4, 1 / 0)")); + Assert.IsTrue((bool)TestSupport.Evaluate("in(4, 1, 2, 4, 1 / 0)")); } } } diff --git a/Evaluant.Calculator.Tests/EvaluationOptionTests.cs b/Evaluant.Calculator.Tests/EvaluationOptionTests.cs index 3e4a8a1..81ed10a 100644 --- a/Evaluant.Calculator.Tests/EvaluationOptionTests.cs +++ b/Evaluant.Calculator.Tests/EvaluationOptionTests.cs @@ -47,7 +47,7 @@ public void IterateParameters_WithMatchingEnumerableParameters_ReturnsResultPerI var result = (IList)expression.Evaluate(); - Assert.AreEqual(3, result.Count); + Assert.HasCount(3, result); Assert.AreEqual(11, result[0]); Assert.AreEqual(22, result[1]); Assert.AreEqual(33, result[2]); @@ -75,7 +75,7 @@ public void IterateParameters_WithMixedScalarAndEnumerableParameters_ReusesScala var result = (IList)expression.Evaluate(); - Assert.AreEqual(3, result.Count); + Assert.HasCount(3, result); Assert.AreEqual(11, result[0]); Assert.AreEqual(12, result[1]); Assert.AreEqual(13, result[2]); @@ -89,7 +89,7 @@ public void IterateParameters_WithEmptyEnumerable_ReturnsEmptyResultList() var result = (IList)expression.Evaluate(); - Assert.AreEqual(0, result.Count); + Assert.IsEmpty(result); } [TestMethod] @@ -100,7 +100,7 @@ public void IterateParameters_WithOnlyScalarParameters_ReturnsEmptyResultList() var result = (IList)expression.Evaluate(); - Assert.AreEqual(0, result.Count); + Assert.IsEmpty(result); } [TestMethod] diff --git a/Evaluant.Calculator.Tests/Fixtures.cs b/Evaluant.Calculator.Tests/Fixtures.cs index 5d29a92..b96eb3a 100644 --- a/Evaluant.Calculator.Tests/Fixtures.cs +++ b/Evaluant.Calculator.Tests/Fixtures.cs @@ -45,7 +45,7 @@ public void ShouldParseValues() Assert.AreEqual(123456, new Expression("123456").Evaluate()); Assert.AreEqual(new DateTime(2001, 01, 01), new Expression("#01/01/2001#").Evaluate()); Assert.AreEqual(123.456d, new Expression("123.456").Evaluate()); - Assert.AreEqual(true, new Expression("true").Evaluate()); + Assert.IsTrue((bool)new Expression("true").Evaluate()); Assert.AreEqual("true", new Expression("'true'").Evaluate()); Assert.AreEqual("azerty", new Expression("'azerty'").Evaluate()); } @@ -190,18 +190,18 @@ public void ShouldEvaluateInOperator() ein.Parameters["1"] = 2; ein.Parameters["2"] = 5; - Assert.AreEqual(true, ein.Evaluate()); + Assert.IsTrue((bool)ein.Evaluate()); var eout = new Expression("in((2 + 2), [1], [2], 1 + 2, 3)"); eout.Parameters["1"] = 2; eout.Parameters["2"] = 5; - Assert.AreEqual(false, eout.Evaluate()); + Assert.IsFalse((bool)eout.Evaluate()); // Should work with strings var estring = new Expression("in('to' + 'to', 'titi', 'toto')"); - Assert.AreEqual(true, estring.Evaluate()); + Assert.IsTrue((bool)estring.Evaluate()); } @@ -283,7 +283,7 @@ public void ShouldThrowAnExpcetionWhenInvalidNumber() [TestMethod] public void ShouldNotRoundDecimalValues() { - Assert.AreEqual(false, new Expression("0 <= -0.6").Evaluate()); + Assert.IsFalse((bool)new Expression("0 <= -0.6").Evaluate()); } [TestMethod] @@ -518,7 +518,7 @@ public void CustomFunctionShouldReturnNull() Assert.IsTrue(args.HasResult); }; - Assert.AreEqual(null, e.Evaluate()); + Assert.IsNull(e.Evaluate()); } [TestMethod] @@ -534,14 +534,14 @@ public void CustomParametersShouldReturnNull() Assert.IsTrue(args.HasResult); }; - Assert.AreEqual(null, e.Evaluate()); + Assert.IsNull(e.Evaluate()); } [TestMethod] public void ShouldCompareDates() { - Assert.AreEqual(true, new Expression("#1/1/2009#==#1/1/2009#").Evaluate()); - Assert.AreEqual(false, new Expression("#2/1/2009#==#1/1/2009#").Evaluate()); + Assert.IsTrue((bool)new Expression("#1/1/2009#==#1/1/2009#").Evaluate()); + Assert.IsFalse((bool)new Expression("#2/1/2009#==#1/1/2009#").Evaluate()); } [TestMethod] @@ -573,7 +573,7 @@ public void ShouldHandleLongValues() [TestMethod] public void ShouldCompareLongValues() { - Assert.AreEqual(false, new Expression("(0=1500000)||(((0+2200000000)-1500000)<0)").Evaluate()); + Assert.IsFalse((bool)new Expression("(0=1500000)||(((0+2200000000)-1500000)<0)").Evaluate()); } [TestMethod] @@ -616,7 +616,7 @@ public void ShouldShortCircuitBooleanExpressions() var e = new Expression("([a] != 0) && ([b]/[a]>2)"); e.Parameters["a"] = 0; - Assert.AreEqual(false, e.Evaluate()); + Assert.IsFalse((bool)e.Evaluate()); } [TestMethod] diff --git a/Evaluant.Calculator.Tests/NumericTestCase.cs b/Evaluant.Calculator.Tests/NumericTestCase.cs index e1f7988..1d2f26c 100644 --- a/Evaluant.Calculator.Tests/NumericTestCase.cs +++ b/Evaluant.Calculator.Tests/NumericTestCase.cs @@ -72,8 +72,9 @@ public void Verify() if (!String.IsNullOrEmpty(ExpectedMessageFragment)) { - Assert.IsTrue( - exception.Message.IndexOf(ExpectedMessageFragment) >= 0, + StringAssert.Contains( + exception.Message, + ExpectedMessageFragment, Name + " message should contain '" + ExpectedMessageFragment + "' but was '" + exception.Message + "'"); } } diff --git a/Evaluant.Calculator.Tests/TestSupport.cs b/Evaluant.Calculator.Tests/TestSupport.cs index cd8e16a..0b27f5e 100644 --- a/Evaluant.Calculator.Tests/TestSupport.cs +++ b/Evaluant.Calculator.Tests/TestSupport.cs @@ -32,8 +32,9 @@ public static TException Throws(Action action) public static void StringContains(string text, string expectedText) { Assert.IsNotNull(text); - Assert.IsTrue( - text.Contains(expectedText), + StringAssert.Contains( + text, + expectedText, "Expected <" + text + "> to contain <" + expectedText + ">."); } } diff --git a/NCalc.sln b/NCalc.sln index 5118a1d..bb60763 100644 --- a/NCalc.sln +++ b/NCalc.sln @@ -5,7 +5,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject LocalTestRun.testrunconfig = LocalTestRun.testrunconfig NCalc.20.vsmdi = NCalc.20.vsmdi - NCalc.vsmdi = NCalc.vsmdi EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc", "Evaluant.Calculator\NCalc.csproj", "{5F014003-50D8-49E0-8AFE-91D38DCCC97C}" @@ -14,10 +13,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc.Play", "Evaluant.Calc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc.Tests", "Evaluant.Calculator.Tests\NCalc.Tests.csproj", "{F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NCalc.FrameworkCompatibility.Tests", "Evaluant.Calculator.FrameworkCompatibility.Tests\NCalc.FrameworkCompatibility.Tests.csproj", "{0A580F51-5792-44A9-8818-C05072105D74}" +EndProject Global - GlobalSection(TestCaseManagementSettings) = postSolution - CategoryFile = NCalc.vsmdi - EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU @@ -35,6 +33,10 @@ Global {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}.Debug|Any CPU.Build.0 = Debug|Any CPU {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}.Release|Any CPU.ActiveCfg = Release|Any CPU {F8E5E800-F842-4F84-A0C6-B3E1E5DB2BB4}.Release|Any CPU.Build.0 = Release|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A580F51-5792-44A9-8818-C05072105D74}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index ef6141b..5752be5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ -> [!WARNING] -> This project is unmaintained and is no longer being actively developed. Please use [NCalc](https://github.com/ncalc/ncalc) instead. +# NCalc + +NCalc is a small expression evaluator for .NET applications. It parses a string expression into an abstract syntax tree and evaluates it with the parameters, functions, and options supplied by the host application. + +It is useful when an application needs user-editable rules, lightweight formulas, configuration-driven calculations, or small bits of business logic without compiling code at runtime. + +## Install + +```sh +dotnet add package NCalc +``` + +NCalc 2.x ships assets for: + +- .NET Standard 2.0 +- .NET Framework 3.5 + +## Quick Example + +```csharp +using NCalc; + +Expression expression = new Expression("2 * ([quantity] + 5)"); +expression.Parameters["quantity"] = 3; + +object result = expression.Evaluate(); +Console.WriteLine(result); // 16 +``` + +Parameters can be supplied directly: + +```csharp +Expression expression = new Expression("[price] * [quantity]"); +expression.Parameters["price"] = 12.5m; +expression.Parameters["quantity"] = 4; + +object total = expression.Evaluate(); +``` + +Functions can be handled lazily by the host application: + +```csharp +Expression expression = new Expression("discount([subtotal])"); +expression.Parameters["subtotal"] = 100m; + +expression.EvaluateFunction += (name, args) => +{ + if (name == "discount") + { + decimal subtotal = Convert.ToDecimal(args.Parameters[0].Evaluate()); + args.Result = subtotal * 0.9m; + } +}; + +object discountedTotal = expression.Evaluate(); +``` + +## Expression Features + +NCalc supports common arithmetic, logical, comparison, bitwise, and conditional expressions, including: + +- arithmetic operators such as `+`, `-`, `*`, `/`, and `%` +- comparison operators such as `=`, `!=`, `<`, `<=`, `>`, and `>=` +- logical operators such as `and`, `or`, and `not` +- ternary-style evaluation through `if(condition, whenTrue, whenFalse)` +- strings, numbers, booleans, dates, and parameters +- custom functions and parameter resolution callbacks + +The evaluator preserves the long-standing NCalc behavior around numeric conversions, culture-sensitive parsing, exception messages, lazy parameter evaluation, and generated parser surface area. Those compatibility points are covered by the test suite because existing applications often rely on them in quiet, surprising ways. + +## Compatibility Notes + +NCalc 2.x is a major release because the strong-name signing key changed. The old NCalc 1.3.8 assembly used public key token `973cde3f1cafed03`; NCalc 2.x uses public key token `f59bbff0fd43f598`. + +Source-compatible consumers can upgrade by rebuilding against the 2.x package. Already-compiled .NET Framework applications and libraries that reference the old strong-name identity must be recompiled; binding redirects cannot redirect one public key token to another. + +The NuGet package keeps the ANTLR runtime surface embedded in `NCalc.dll`, matching the historical package shape. That means parser and lexer consumers can continue to use public types such as `NCalcLexer`, `NCalcParser`, and `Antlr.Runtime.Tree.CommonTree` from the package without adding a separate ANTLR dependency. + +## Validation + +The repository includes a local quality gate: + +```sh +scripts/validate.sh +``` + +The gate builds the library, builds the solution, runs the modern test suite with coverage thresholds, runs framework compatibility checks, creates a signed package, checks package compatibility against NCalc 1.3.8, runs SDK package validation, and performs consumer smoke tests. + +Release packaging also runs a Windows .NET Framework consumer smoke before publishing to NuGet. + +## License + +NCalc is released under the MIT License. diff --git a/docs/modernization-release.md b/docs/modernization-release.md index 1f29dec..57fee06 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -27,9 +27,9 @@ dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build --output artifacts/packages /p:ContinuousIntegrationBuild=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg ``` -The pack command should emit both `.nupkg` and `.snupkg` files after the SDK-style project conversion. The current legacy project layout still targets .NET Framework 3.5, so these commands depend on the earlier SDK-style conversion PRs. +The pack command emits both `.nupkg` and `.snupkg` files after the SDK-style project conversion. These commands depend on the earlier modernization PRs being applied first. -The GitHub CI workflow runs restore, build, and test on Ubuntu, macOS, and Windows. A dedicated Ubuntu quality-gates job runs `scripts/validate.sh`, including signed package creation, package compatibility, API compatibility, coverage, local-feed consumer smoke, and the compatibility harness against both `net35` under Mono and `netstandard2.0` under modern .NET. CI validates the library and modern test project directly because the full solution still contains the legacy `Evaluant.Calculator.Play` .NET Framework 3.5 sample project. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. +The GitHub CI workflow runs restore, build, and test on Ubuntu, macOS, and Windows. A dedicated Ubuntu quality-gates job runs `scripts/validate.sh`, including solution restore/build, signed package creation, package compatibility, API compatibility, coverage, local-feed consumer smoke, and the compatibility harness against both `net35` under Mono and `netstandard2.0` under modern .NET. Windows remains part of the required gate because the modernization must preserve compatibility expectations for existing .NET Framework consumers. ## Release Quality Gate @@ -55,9 +55,10 @@ The package declares the MIT license expression and the repository contains a ma The old `NCalc` `1.3.8` strong-name private key is unavailable. Its public key token was `973cde3f1cafed03`, so a newly signed modernization package cannot be binary-compatible for already-compiled strong-name consumers. This release line is therefore `2.0.0` or later, uses assembly version `2.0.0.0`, and uses the new public key token `f59bbff0fd43f598`. -Do not commit private signing keys. Local production-token validation should use the ignored private key for token `f59bbff0fd43f598`: +Do not commit private signing keys. Local production-token validation should use a private key outside the repository, with token `f59bbff0fd43f598`: ```sh +NCalcStrongNameKeyFile=~/.ncalc/signing/NCalc.snk \ scripts/validate.sh ``` @@ -69,17 +70,17 @@ NCalcStrongNameKeyFile=/secure/path/NCalc.snk scripts/validate.sh For temporary non-release validation with a throwaway key, generate an ignored key and set `NCALC_EXPECTED_PUBLIC_KEY_TOKEN` to the generated token. Do not publish a package from a throwaway key. +`scripts/validate.sh` rejects tracked key files and also rejects private-key paths inside the repository, even if the file is ignored. Keep the key in `~/.ncalc/signing`, a secure local directory, or the release runner's temp directory. + GitHub release packaging requires `NCALC_STRONG_NAME_KEY_BASE64`, a base64-encoded `.snk` stored as a repository secret. The release workflow decodes that secret to the runner temp directory and fails if the public key token does not match `f59bbff0fd43f598`. For non-release CI, the quality-gates workflow can generate an ephemeral key when the secret is unavailable. That still proves the package is signable, repacked, and source-compatible, while the release workflow remains the authority for the stable production token. -## Deferred Package Validation And Restore Locking - -Package validation settings are intentionally not enabled in this lane yet. They should be added after the SDK-style project exists and a real package baseline/versioning policy is chosen; enabling package validation without a baseline here would either be untestable on this branch or risk noisy validation with no compatibility signal. +## Package Validation And Restore Locking -Restore lock files are also intentionally deferred. Generate and review `packages.lock.json` after PR1 and PR2 have finalized the SDK-style project references and test dependencies; do not check in a hand-written lock file. +Package validation is enabled in the release gate against the `NCalc` `1.3.8` baseline. The package compatibility script adds checks that SDK package validation does not cover, including strong-name token, assembly version, package major version, license metadata, embedded ANTLR surface, and parser/lexer consumer smoke. -Solution-wide validation should be restored after the sample application is modernized, removed from the solution, or moved behind an explicit Windows/Mono-only gate. +Restore lock files are still intentionally deferred. Generate and review `packages.lock.json` only after the dependency set has settled; do not check in a hand-written lock file. ## GitHub Release Flow diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 0f50198..cacf618 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -24,12 +24,14 @@ Required gates: The `NCalc` `1.3.8` package was signed with public key token `973cde3f1cafed03`. The private key for that token is not available, so the modernization release is a major-version package identity break even when source and runtime behavior are preserved. -The new major-version key generated for this release line has public key token `f59bbff0fd43f598`. Private key material must never be committed. The repository ignores `.snk`, `.pfx`, `.p12`, and `.pem` files, and `scripts/validate.sh` expects one of these signing paths: +The new major-version key generated for this release line has public key token `f59bbff0fd43f598`. Private key material must never be committed or stored inside the worktree. The repository ignores `.snk`, `.pfx`, `.p12`, and `.pem` files, and `scripts/validate.sh` expects one of these signing paths: -- local production-token validation: the ignored private key for token `f59bbff0fd43f598` at `build/NCalc.snk`, or `NCalcStrongNameKeyFile=/secure/path/NCalc.snk`; +- local production-token validation: the ignored private key for token `f59bbff0fd43f598` at `~/.ncalc/signing/NCalc.snk`, or `NCalcStrongNameKeyFile=/secure/path/NCalc.snk`; - local throwaway validation: any ignored `.snk`, plus `NCALC_EXPECTED_PUBLIC_KEY_TOKEN` set to that key's token; - release CI: a repository secret named `NCALC_STRONG_NAME_KEY_BASE64`, containing the base64-encoded private `.snk`. +`scripts/validate.sh` rejects tracked signing files and rejects `NCalcStrongNameKeyFile` values that resolve inside the repository, even if those files are ignored by Git. + Because the public key token changes, already-compiled .NET Framework consumers that reference `NCalc, PublicKeyToken=973cde3f1cafed03` must rebuild against the major-version package. Binding redirects cannot redirect one strong-name public key token to another. The package compatibility gate records this deliberately as a major-version identity break rather than pretending binary compatibility exists. ## Local Validation @@ -48,14 +50,16 @@ The script validates the production package and test project directly: 4. `dotnet build Evaluant.Calculator/NCalc.csproj --configuration Release --no-restore` 5. `dotnet build Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-restore` 6. `dotnet build Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj --configuration Release --no-restore` -7. Compatibility assertions against the `netstandard2.0` asset using `dotnet run` -8. Compatibility assertions against the `net35` asset using `mono` on Unix-like systems or the `.exe` directly on Windows -9. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` -10. coverage threshold enforcement, currently line coverage >= 93% and branch coverage >= 91% -11. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` -12. package compatibility validation for signing, license metadata, embedded ANTLR, parser source compatibility, and the deliberate major-version identity break -13. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package -14. A modern .NET consumer smoke from the packed local package +7. `dotnet restore NCalc.sln` +8. `dotnet build NCalc.sln --configuration Release --no-restore` +9. Compatibility assertions against the `netstandard2.0` asset using `dotnet run` +10. Compatibility assertions against the `net35` asset using `mono` on Unix-like systems or the `.exe` directly on Windows +11. `dotnet test Evaluant.Calculator.Tests/NCalc.Tests.csproj --configuration Release --no-build --collect:"XPlat Code Coverage"` +12. coverage threshold enforcement, currently line coverage >= 93% and branch coverage >= 91% +13. `dotnet pack Evaluant.Calculator/NCalc.csproj --configuration Release --no-build` +14. package compatibility validation for package version, signing, license metadata, embedded ANTLR, parser source compatibility, and the deliberate major-version identity break +15. SDK package validation with `EnablePackageValidation=true` after hydrating the `NCalc` `1.3.8` baseline package +16. A modern .NET consumer smoke from the packed local package Coverage output is written to `artifacts/coverage` and must include `coverage.cobertura.xml`. `scripts/validate.sh` fails if line coverage falls below `93%` or branch coverage falls below `91%`. These thresholds sit below the current measured coverage so they prevent accidental erosion while leaving a small margin for harmless branch-shape changes. @@ -63,7 +67,7 @@ The Framework compatibility harness intentionally uses a plain console runner ra Local validation packs `NCalc` as `2.0.0-qualitygates` by default. This avoids NuGet reusing a globally cached `NCalc` `1.3.8` baseline package during consumer smoke tests and reflects the major-version package identity change. Override with `PACKAGE_VERSION=...` only when the replacement version is deliberate. -The full legacy `NCalc.sln` still contains `Evaluant.Calculator.Play`, a .NET Framework 3.5 sample project. Solution-wide validation should be re-enabled after that sample project is either modernized, removed from the solution, or gated to Windows/Mono explicitly. +Because NCalc 2.x uses a new strong-name key, local validation and release packaging reject package versions below `2.0.0` by default. ## API Compatibility diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh index 0e094ef..d93a56d 100755 --- a/scripts/consumer-smoke.sh +++ b/scripts/consumer-smoke.sh @@ -2,11 +2,18 @@ set -euo pipefail -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPOSITORY_ROOT readonly PACKAGE_SOURCE="${1:-${REPOSITORY_ROOT}/artifacts/packages}" readonly PACKAGE_VERSION="${2:-${PACKAGE_VERSION:-}}" -readonly SMOKE_ROOT="${SMOKE_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/ncalc-consumer-smoke.XXXXXX")}" + +if [[ -z "${SMOKE_ROOT:-}" ]]; then + SMOKE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ncalc-consumer-smoke.XXXXXX")" +fi + +readonly SMOKE_ROOT readonly SMOKE_PROJECT="${SMOKE_ROOT}/NCalc.ConsumerSmoke/NCalc.ConsumerSmoke.csproj" readonly SMOKE_PACKAGES_DIR="${SMOKE_ROOT}/packages" diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index f850cbc..ec7e249 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -6,8 +6,10 @@ set -euo pipefail -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPOSITORY_ROOT readonly PACKAGE_SOURCE="${1:-${REPOSITORY_ROOT}/artifacts/packages}" readonly PACKAGE_VERSION="${2:-${PACKAGE_VERSION:-}}" readonly BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" @@ -17,7 +19,14 @@ readonly EXPECTED_ASSEMBLY_VERSION="${NCALC_EXPECTED_ASSEMBLY_VERSION:-2.0.0.0}" readonly EXPECTED_LICENSE_EXPRESSION="${NCALC_EXPECTED_LICENSE_EXPRESSION:-MIT}" readonly LEGACY_PUBLIC_KEY_TOKEN="${NCALC_LEGACY_PUBLIC_KEY_TOKEN:-973cde3f1cafed03}" readonly PACKAGE_IDENTITY_MODE="${NCALC_PACKAGE_IDENTITY_MODE:-major}" -readonly WORK_ROOT="${PACKAGE_COMPATIBILITY_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/ncalc-package-compatibility.XXXXXX")}" +readonly MINIMUM_MAJOR_PACKAGE_VERSION="${NCALC_MINIMUM_MAJOR_PACKAGE_VERSION:-2}" +if [[ -z "${PACKAGE_COMPATIBILITY_ROOT:-}" ]]; then + WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ncalc-package-compatibility.XXXXXX")" +else + WORK_ROOT="${PACKAGE_COMPATIBILITY_ROOT}" +fi + +readonly WORK_ROOT readonly CURRENT_EXTRACT_ROOT="${WORK_ROOT}/current" readonly BASELINE_EXTRACT_ROOT="${WORK_ROOT}/baseline" @@ -160,6 +169,7 @@ validate_current_package_shape() { local nuspec_path nuspec_path="$(find "${CURRENT_EXTRACT_ROOT}" -maxdepth 1 -name '*.nuspec' -print -quit)" require_file "${nuspec_path}" + assert_package_version_metadata "${nuspec_path}" assert_license_metadata "${nuspec_path}" if grep -q 'Antlr3.Runtime' "${nuspec_path}"; then @@ -182,6 +192,38 @@ validate_current_package_shape() { done < <(current_assemblies) } +assert_package_version_metadata() { + local nuspec_path="$1" + local nuspec_version + local package_major_version + + nuspec_version="$(grep -Eo '[^<]+' "${nuspec_path}" | sed -E 's###g' | head -n 1)" + + if [[ -n "${PACKAGE_VERSION}" && "${nuspec_version}" != "${PACKAGE_VERSION}" ]]; then + echo "Expected NuGet package version ${PACKAGE_VERSION} in ${nuspec_path}, got ${nuspec_version:-}." >&2 + exit 1 + fi + + package_major_version="$(extract_package_major_version "${nuspec_version}")" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "major" && "${package_major_version}" -lt "${MINIMUM_MAJOR_PACKAGE_VERSION}" ]]; then + echo "Package version ${nuspec_version} is invalid for a new-key major release. Expected major version ${MINIMUM_MAJOR_PACKAGE_VERSION} or later." >&2 + exit 1 + fi +} + +extract_package_major_version() { + local package_version="$1" + + if [[ "${package_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return + fi + + echo "Unsupported package version format: ${package_version}" >&2 + exit 2 +} + assert_license_metadata() { local nuspec_path="$1" diff --git a/scripts/validate.sh b/scripts/validate.sh index ca2af36..5893f7a 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -2,8 +2,10 @@ set -euo pipefail -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPOSITORY_ROOT readonly CONFIGURATION="${CONFIGURATION:-Release}" readonly COVERAGE_RESULTS_DIR="${COVERAGE_RESULTS_DIR:-${REPOSITORY_ROOT}/artifacts/coverage}" readonly PACKAGE_OUTPUT_DIR="${PACKAGE_OUTPUT_DIR:-${REPOSITORY_ROOT}/artifacts/packages}" @@ -12,10 +14,13 @@ readonly API_COMPAT_BASELINE_VERSION="${API_COMPAT_BASELINE_VERSION:-1.3.8}" readonly NUGET_PACKAGE_SOURCE="${NUGET_PACKAGE_SOURCE:-https://api.nuget.org/v3/index.json}" readonly MINIMUM_LINE_COVERAGE="${MINIMUM_LINE_COVERAGE:-0.93}" readonly MINIMUM_BRANCH_COVERAGE="${MINIMUM_BRANCH_COVERAGE:-0.91}" -readonly DEFAULT_LOCAL_STRONG_NAME_KEY="${REPOSITORY_ROOT}/build/NCalc.snk" +readonly PACKAGE_IDENTITY_MODE="${NCALC_PACKAGE_IDENTITY_MODE:-major}" +readonly MINIMUM_MAJOR_PACKAGE_VERSION="${NCALC_MINIMUM_MAJOR_PACKAGE_VERSION:-2}" +readonly DEFAULT_LOCAL_STRONG_NAME_KEY="${HOME}/.ncalc/signing/NCalc.snk" readonly CORE_PROJECT="${CORE_PROJECT:-Evaluant.Calculator/NCalc.csproj}" readonly TEST_PROJECT="${TEST_PROJECT:-Evaluant.Calculator.Tests/NCalc.Tests.csproj}" readonly FRAMEWORK_COMPATIBILITY_PROJECT="${FRAMEWORK_COMPATIBILITY_PROJECT:-Evaluant.Calculator.FrameworkCompatibility.Tests/NCalc.FrameworkCompatibility.Tests.csproj}" +readonly SOLUTION_FILE="${SOLUTION_FILE:-NCalc.sln}" main() { cd "${REPOSITORY_ROOT}" @@ -23,10 +28,13 @@ main() { require_command dotnet require_command python3 require_sdk_style_core_project + require_no_tracked_signing_keys + validate_package_version_policy "${PACKAGE_VERSION}" prepare_strong_name_key restore_projects build_projects + build_solution run_framework_compatibility_tests run_tests_with_coverage pack_package @@ -62,6 +70,45 @@ MESSAGE echo "SDK-style core project detected." } +require_no_tracked_signing_keys() { + print_section "Checking signing material guard" + + local tracked_signing_files + tracked_signing_files="$(git ls-files '*.snk' '*.pfx' '*.p12' '*.pem')" + + if [[ -n "${tracked_signing_files}" ]]; then + echo "Private signing material must not be tracked:" >&2 + echo "${tracked_signing_files}" >&2 + exit 1 + fi + + echo "No tracked signing key files detected." +} + +validate_package_version_policy() { + local package_version="$1" + local package_major_version + + package_major_version="$(extract_package_major_version "${package_version}")" + + if [[ "${PACKAGE_IDENTITY_MODE}" == "major" && "${package_major_version}" -lt "${MINIMUM_MAJOR_PACKAGE_VERSION}" ]]; then + echo "Package version ${package_version} is invalid for a new-key major release. Expected major version ${MINIMUM_MAJOR_PACKAGE_VERSION} or later." >&2 + exit 1 + fi +} + +extract_package_major_version() { + local package_version="$1" + + if [[ "${package_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + return + fi + + echo "Unsupported package version format: ${package_version}" >&2 + exit 2 +} + prepare_strong_name_key() { print_section "Checking strong-name signing key" @@ -88,6 +135,20 @@ MESSAGE exit 2 fi + NCalcStrongNameKeyFile="$(cd "$(dirname "${NCalcStrongNameKeyFile}")" && pwd)/$(basename "${NCalcStrongNameKeyFile}")" + export NCalcStrongNameKeyFile + + if [[ "${NCalcStrongNameKeyFile}" == "${REPOSITORY_ROOT}/"* ]]; then + cat >&2 < Date: Sat, 20 Jun 2026 13:16:21 +0100 Subject: [PATCH 14/18] Restore legacy ANTLR return scope compatibility --- .../Program.cs | 27 ++++++++++++++ Evaluant.Calculator/AntlrCompatibility.cs | 10 ++++++ Evaluant.Calculator/NCalcParser.cs | 36 +++++++++---------- .../Properties/AssemblyInfo.cs | 1 + README.md | 2 +- docs/modernization-release.md | 2 +- docs/quality-gates.md | 2 +- scripts/package-compatibility.sh | 14 ++++++-- 8 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 Evaluant.Calculator/AntlrCompatibility.cs diff --git a/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs b/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs index b30b626..0af1621 100644 --- a/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs +++ b/Evaluant.Calculator.FrameworkCompatibility.Tests/Program.cs @@ -1,6 +1,8 @@ using System; using System.Globalization; using System.Threading; +using Antlr.Runtime; +using Antlr.Runtime.Tree; using NCalc.Domain; using DomainValueType = NCalc.Domain.ValueType; @@ -24,6 +26,7 @@ private static int Main() Thread.CurrentThread.CurrentUICulture = new CultureInfo("fr-FR"); RunExpressionCases(); + RunParserSurfaceCases(); RunNumbersCases(); RunDomainCases(); RunCallbackCases(); @@ -80,6 +83,30 @@ private static void RunExpressionCases() }); } + private static void RunParserSurfaceCases() + { + Run("ParserNcalcExpression_WithLegacyReturnScopeVariable_DoesExposeAstTree", delegate + { + NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); + CommonTokenStream tokens = new CommonTokenStream(lexer); + NCalcParser parser = new NCalcParser(tokens); + + ParserRuleReturnScope parserResult = parser.ncalcExpression(); + IAstRuleReturnScope astResult = (IAstRuleReturnScope)parserResult; + CommonTree tree = astResult.Tree as CommonTree; + + if (tree == null) + { + throw new InvalidOperationException("Expected legacy parser return scope to expose a CommonTree."); + } + + if (parserResult.Start == null || parserResult.Stop == null) + { + throw new InvalidOperationException("Expected legacy parser return scope to expose token boundaries."); + } + }); + } + private static void RunNumbersCases() { Run("NumbersAdd_WithStringCommaDecimalUnderFrenchCulture_DoesParseUsingCurrentCulture", delegate diff --git a/Evaluant.Calculator/AntlrCompatibility.cs b/Evaluant.Calculator/AntlrCompatibility.cs new file mode 100644 index 0000000..049aef5 --- /dev/null +++ b/Evaluant.Calculator/AntlrCompatibility.cs @@ -0,0 +1,10 @@ +namespace Antlr.Runtime +{ + /// + /// Preserves the non-generic parser return-scope type exposed by the legacy + /// NCalc package while the embedded ANTLR runtime uses the newer generic base. + /// + public class ParserRuleReturnScope : ParserRuleReturnScope + { + } +} diff --git a/Evaluant.Calculator/NCalcParser.cs b/Evaluant.Calculator/NCalcParser.cs index ed75d4d..c3f79af 100644 --- a/Evaluant.Calculator/NCalcParser.cs +++ b/Evaluant.Calculator/NCalcParser.cs @@ -185,7 +185,7 @@ public override void DisplayRecognitionError(String[] tokenNames, RecognitionExc partial void LeaveRule(string ruleName, int ruleIndex); #region Rules - public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -263,7 +263,7 @@ public NCalcParser.ncalcExpression_return ncalcExpression() } // $ANTLR end "ncalcExpression" - public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -392,7 +392,7 @@ private NCalcParser.logicalExpression_return logicalExpression() } // $ANTLR end "logicalExpression" - public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -533,7 +533,7 @@ private NCalcParser.conditionalExpression_return conditionalExpression() } // $ANTLR end "conditionalExpression" - public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -674,7 +674,7 @@ private NCalcParser.booleanAndExpression_return booleanAndExpression() } // $ANTLR end "booleanAndExpression" - public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -805,7 +805,7 @@ private NCalcParser.bitwiseOrExpression_return bitwiseOrExpression() } // $ANTLR end "bitwiseOrExpression" - public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -936,7 +936,7 @@ private NCalcParser.bitwiseXOrExpression_return bitwiseXOrExpression() } // $ANTLR end "bitwiseXOrExpression" - public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -1067,7 +1067,7 @@ private NCalcParser.bitwiseAndExpression_return bitwiseAndExpression() } // $ANTLR end "bitwiseAndExpression" - public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -1270,7 +1270,7 @@ private NCalcParser.equalityExpression_return equalityExpression() } // $ANTLR end "equalityExpression" - public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -1499,7 +1499,7 @@ private NCalcParser.relationalExpression_return relationalExpression() } // $ANTLR end "relationalExpression" - public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -1682,7 +1682,7 @@ private NCalcParser.shiftExpression_return shiftExpression() } // $ANTLR end "shiftExpression" - public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -1865,7 +1865,7 @@ private NCalcParser.additiveExpression_return additiveExpression() } // $ANTLR end "additiveExpression" - public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -2073,7 +2073,7 @@ private NCalcParser.multiplicativeExpression_return multiplicativeExpression() } // $ANTLR end "multiplicativeExpression" - public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -2288,7 +2288,7 @@ private NCalcParser.unaryExpression_return unaryExpression() } // $ANTLR end "unaryExpression" - public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope + public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; @@ -2486,7 +2486,7 @@ private NCalcParser.primaryExpression_return primaryExpression() } // $ANTLR end "primaryExpression" - public class value_return : ParserRuleReturnScope, IAstRuleReturnScope + public class value_return : ParserRuleReturnScope, IAstRuleReturnScope { public ValueExpression value; private CommonTree _tree; @@ -2699,7 +2699,7 @@ private NCalcParser.value_return value() } // $ANTLR end "value" - public class identifier_return : ParserRuleReturnScope, IAstRuleReturnScope + public class identifier_return : ParserRuleReturnScope, IAstRuleReturnScope { public Identifier value; private CommonTree _tree; @@ -2816,7 +2816,7 @@ private NCalcParser.identifier_return identifier() } // $ANTLR end "identifier" - public class expressionList_return : ParserRuleReturnScope, IAstRuleReturnScope + public class expressionList_return : ParserRuleReturnScope, IAstRuleReturnScope { public List value; private CommonTree _tree; @@ -2947,7 +2947,7 @@ private NCalcParser.expressionList_return expressionList() } // $ANTLR end "expressionList" - public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope + public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope { public List value; private CommonTree _tree; diff --git a/Evaluant.Calculator/Properties/AssemblyInfo.cs b/Evaluant.Calculator/Properties/AssemblyInfo.cs index 50e37bc..92ccad2 100644 --- a/Evaluant.Calculator/Properties/AssemblyInfo.cs +++ b/Evaluant.Calculator/Properties/AssemblyInfo.cs @@ -13,6 +13,7 @@ [assembly: AssemblyCopyright("Sebastien Ros")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: System.CLSCompliant(false)] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/README.md b/README.md index 5752be5..a760b61 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ NCalc 2.x is a major release because the strong-name signing key changed. The ol Source-compatible consumers can upgrade by rebuilding against the 2.x package. Already-compiled .NET Framework applications and libraries that reference the old strong-name identity must be recompiled; binding redirects cannot redirect one public key token to another. -The NuGet package keeps the ANTLR runtime surface embedded in `NCalc.dll`, matching the historical package shape. That means parser and lexer consumers can continue to use public types such as `NCalcLexer`, `NCalcParser`, and `Antlr.Runtime.Tree.CommonTree` from the package without adding a separate ANTLR dependency. +The NuGet package keeps the ANTLR runtime surface embedded in `NCalc.dll`, matching the historical package shape. Parser and lexer consumers can continue to use public types such as `NCalcLexer`, `NCalcParser`, `Antlr.Runtime.ParserRuleReturnScope`, and `Antlr.Runtime.Tree.CommonTree` from the package without adding a separate ANTLR dependency. ## Validation diff --git a/docs/modernization-release.md b/docs/modernization-release.md index 57fee06..28e95a2 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -78,7 +78,7 @@ For non-release CI, the quality-gates workflow can generate an ephemeral key whe ## Package Validation And Restore Locking -Package validation is enabled in the release gate against the `NCalc` `1.3.8` baseline. The package compatibility script adds checks that SDK package validation does not cover, including strong-name token, assembly version, package major version, license metadata, embedded ANTLR surface, and parser/lexer consumer smoke. +Package validation is enabled in the release gate against the `NCalc` `1.3.8` baseline. The package compatibility script adds checks that SDK package validation does not cover, including strong-name token, assembly version, package major version, license metadata, embedded ANTLR surface, and parser/lexer consumer smoke for legacy source patterns such as non-generic `ParserRuleReturnScope`. Restore lock files are still intentionally deferred. Generate and review `packages.lock.json` only after the dependency set has settled; do not check in a hand-written lock file. diff --git a/docs/quality-gates.md b/docs/quality-gates.md index cacf618..b6f4d55 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -124,7 +124,7 @@ It verifies: - the current package declares license expression `MIT`; - `Antlr3.Runtime` is not a package dependency; - the `net35` and `netstandard2.0` assemblies embed public `Antlr.Runtime.*`, `NCalcLexer`, `NCalcParser`, and parser return-scope types; -- a parser/lexer consumer can compile and run against the packed `net35` assembly; +- a parser/lexer consumer using the legacy non-generic `ParserRuleReturnScope` source pattern can compile and run against the packed `net35` assembly; - a consumer compiled against the legacy package references the old token, documenting why a new key is a major-version binary identity break. ## Consumer Smoke diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index ec7e249..e11f20d 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -187,6 +187,8 @@ validate_current_package_shape() { assert_embedded_type "${assembly_path}" "NCalcLexer" assert_embedded_type "${assembly_path}" "NCalcParser" assert_embedded_type "${assembly_path}" "NCalcParser/ncalcExpression_return" + assert_embedded_type "${assembly_path}" "Antlr.Runtime.ParserRuleReturnScope" + assert_embedded_type "${assembly_path}" 'Antlr.Runtime.ParserRuleReturnScope`1' assert_embedded_type "${assembly_path}" "Antlr.Runtime.IAstRuleReturnScope" assert_embedded_type "${assembly_path}" "Antlr.Runtime.Tree.CommonTree" done < <(current_assemblies) @@ -299,7 +301,7 @@ assert_embedded_type() { local assembly_path="$1" local type_name="$2" - if ! monodis --typedef "${assembly_path}" | grep -q "${type_name}"; then + if ! monodis --typedef "${assembly_path}" | awk -v type_name="${type_name}" '$2 == type_name { found = 1 } END { exit(found ? 0 : 1) }'; then echo "Expected type ${type_name} in ${assembly_path}." >&2 exit 1 fi @@ -392,14 +394,20 @@ internal static class ParserSmoke NCalcLexer lexer = new NCalcLexer(new ANTLRStringStream("1 + 2")); CommonTokenStream tokens = new CommonTokenStream(lexer); NCalcParser parser = new NCalcParser(tokens); - NCalcParser.ncalcExpression_return parserResult = parser.ncalcExpression(); - CommonTree tree = parserResult.Tree as CommonTree; + ParserRuleReturnScope parserResult = parser.ncalcExpression(); + NCalcParser.ncalcExpression_return typedResult = (NCalcParser.ncalcExpression_return)parserResult; + CommonTree tree = typedResult.Tree as CommonTree; if (tree == null) { throw new InvalidOperationException("Expected parser to return a CommonTree."); } + if (parserResult.Start == null || parserResult.Stop == null) + { + throw new InvalidOperationException("Expected parser return scope to expose token boundaries."); + } + Console.WriteLine("Parser smoke passed."); return 0; } From 90dd92908abdb08d9057e51057b1640de7ba398e Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 13:44:18 +0100 Subject: [PATCH 15/18] Restore legacy ANTLR public surface --- .../AntlrCompatibilityTests.cs | 265 +++++++++++ Evaluant.Calculator.Tests/NCalc.Tests.csproj | 1 - Evaluant.Calculator/AntlrCompatibility.cs | 450 +++++++++++++++++- Evaluant.Calculator/NCalcParser.cs | 108 ++--- scripts/consumer-smoke.sh | 21 + scripts/package-compatibility.sh | 119 +++++ 6 files changed, 906 insertions(+), 58 deletions(-) create mode 100644 Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs diff --git a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs new file mode 100644 index 0000000..f5a9fdb --- /dev/null +++ b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections; +using System.IO; +using System.Text; +using Antlr.Runtime; +using Antlr.Runtime.Tree; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NCalc.Tests +{ + [TestClass] + public class AntlrCompatibilityTests + { + [TestMethod] + public void ParserReturnScope_WithLegacyObjectProperties_DoesStoreTokensAndTree() + { + CommonToken start = new CommonToken(1, "start"); + CommonToken stop = new CommonToken(2, "stop"); + CommonTree tree = new CommonTree(start); + RuleReturnScope ruleReturnScope = new RuleReturnScope(); + ParserRuleReturnScope parserRuleReturnScope = new ParserRuleReturnScope(); + TreeRuleReturnScope treeRuleReturnScope = new TreeRuleReturnScope(); + + ruleReturnScope.Start = start; + ruleReturnScope.Stop = stop; + ruleReturnScope.Tree = tree; + parserRuleReturnScope.Start = start; + treeRuleReturnScope.Start = tree; + + Assert.AreSame(start, ruleReturnScope.Start); + Assert.AreSame(stop, ruleReturnScope.Stop); + Assert.AreSame(tree, ruleReturnScope.Tree); + Assert.IsNull(ruleReturnScope.Template); + Assert.AreSame(start, parserRuleReturnScope.Start); + Assert.AreSame(tree, treeRuleReturnScope.Start); + } + + [TestMethod] + public void Token_WithLegacyConstants_DoesExposeCurrentRuntimeValues() + { + AssertLegacyTokenValue(-1, Token.EOF); + AssertLegacyTokenValue(0, Token.INVALID_TOKEN_TYPE); + AssertLegacyTokenValue(1, Token.EOR_TOKEN_TYPE); + AssertLegacyTokenValue(2, Token.DOWN); + AssertLegacyTokenValue(3, Token.UP); + AssertLegacyTokenValue(0, Token.DEFAULT_CHANNEL); + AssertLegacyTokenValue(99, Token.HIDDEN_CHANNEL); + AssertLegacyTokenValue(4, Token.MIN_TOKEN_TYPE); + Assert.IsNotNull(Token.EOF_TOKEN); + Assert.IsNotNull(Token.INVALID_TOKEN); + Assert.IsNotNull(Token.SKIP_TOKEN); + } + + [TestMethod] + public void AntlrFileStream_WithUtf8File_DoesLoadTextAndSourceName() + { + string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + + try + { + File.WriteAllText(filePath, "abc", Encoding.UTF8); + + ANTLRFileStream stream = new ANTLRFileStream(filePath, Encoding.UTF8); + + Assert.AreEqual(filePath, stream.SourceName); + Assert.AreEqual(3, stream.Count); + Assert.AreEqual("abc", stream.Substring(0, 3)); + FileInfo fileInfo = new FileInfo(filePath); + LegacyAntlrFileStream derivedStream = new LegacyAntlrFileStream(); + Assert.AreEqual(fileInfo.Length, derivedStream.ReadFileLength(fileInfo)); + + derivedStream.Load(filePath, Encoding.UTF8); + Assert.AreEqual(filePath, derivedStream.SourceName); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [TestMethod] + public void CollectionUtils_WithLegacyInputs_DoesFormatCollections() + { + Hashtable dictionary = new Hashtable(); + dictionary["one"] = 1; + ArrayList list = new ArrayList(); + list.Add("a"); + list.Add("b"); + + string dictionaryText = Antlr.Runtime.Collections.CollectionUtils.DictionaryToString(dictionary); + string listText = Antlr.Runtime.Collections.CollectionUtils.ListToString(list); + + StringAssert.Contains(dictionaryText, "one=1"); + Assert.AreEqual("[a, b]", listText); + Assert.AreEqual("null", Antlr.Runtime.Collections.CollectionUtils.DictionaryToString(null)); + Assert.AreEqual("null", Antlr.Runtime.Collections.CollectionUtils.ListToString(null)); + } + + [TestMethod] + public void HashList_WithDictionaryOperations_DoesPreserveInsertionAndLookup() + { + Antlr.Runtime.Collections.HashList hashList = new Antlr.Runtime.Collections.HashList(2); + hashList.Add("first", 1); + hashList["second"] = 2; + + Assert.AreEqual(2, hashList.Count); + Assert.IsTrue(hashList.Contains("first")); + Assert.AreEqual(2, hashList["second"]); + Assert.IsFalse(hashList.IsReadOnly); + Assert.IsFalse(hashList.IsFixedSize); + Assert.IsFalse(hashList.IsSynchronized); + Assert.IsNotNull(hashList.SyncRoot); + CollectionAssert.Contains((ICollection)hashList.Keys, "first"); + CollectionAssert.Contains((ICollection)hashList.Values, 2); + + DictionaryEntry[] entries = new DictionaryEntry[2]; + hashList.CopyTo(entries, 0); + CollectionAssert.Contains(new[] { entries[0].Key, entries[1].Key }, "first"); + CollectionAssert.Contains(new[] { entries[0].Key, entries[1].Key }, "second"); + + IDictionaryEnumerator enumerator = hashList.GetEnumerator(); + Assert.IsTrue(enumerator.MoveNext()); + + hashList.Remove("first"); + Assert.IsFalse(hashList.Contains("first")); + hashList.Clear(); + Assert.AreEqual(0, hashList.Count); + } + + [TestMethod] + public void StackList_WithPushPopOperations_DoesBehaveLikeStack() + { + Antlr.Runtime.Collections.StackList stackList = new Antlr.Runtime.Collections.StackList(); + stackList.Push("first"); + stackList.Push("second"); + + Assert.AreEqual("second", stackList.Peek()); + Assert.AreEqual("second", stackList.Pop()); + Assert.AreEqual("first", stackList.Pop()); + Assert.Throws(delegate { stackList.Peek(); }); + } + + [TestMethod] + public void Stats_WithLegacyHelpers_DoesCalculateValuesAndWriteReport() + { + int[] values = { 1, 2, 3 }; + string reportPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + + try + { + Assert.AreEqual(2d, Antlr.Runtime.Misc.Stats.Avg(values)); + Assert.AreEqual(3, Antlr.Runtime.Misc.Stats.Max(values)); + Assert.AreEqual(1, Antlr.Runtime.Misc.Stats.Min(values)); + Assert.AreEqual(6, Antlr.Runtime.Misc.Stats.Sum(values)); + Assert.AreEqual(1d, Antlr.Runtime.Misc.Stats.Stddev(values)); + Assert.AreEqual(0d, Antlr.Runtime.Misc.Stats.Avg(new int[0])); + Assert.AreEqual(0, Antlr.Runtime.Misc.Stats.Max(new int[0])); + Assert.AreEqual(0, Antlr.Runtime.Misc.Stats.Min(new int[0])); + Assert.AreEqual(0d, Antlr.Runtime.Misc.Stats.Stddev(new[] { 1 })); + + Antlr.Runtime.Misc.Stats.WriteReport(reportPath, "report"); + Assert.AreEqual("report", File.ReadAllText(Antlr.Runtime.Misc.Stats.GetAbsoluteFileName(reportPath))); + } + finally + { + if (File.Exists(reportPath)) + { + File.Delete(reportPath); + } + } + } + + [TestMethod] + public void ErrorManager_WithLegacyErrors_DoesThrowInvalidOperationException() + { + Assert.Throws(delegate { Antlr.Runtime.Misc.ErrorManager.Error("error"); }); + Assert.Throws(delegate { Antlr.Runtime.Misc.ErrorManager.InternalError("error"); }); + + InvalidOperationException exception = Assert.Throws( + delegate { Antlr.Runtime.Misc.ErrorManager.InternalError("error", new ApplicationException("inner")); }); + + Assert.IsInstanceOfType(exception.InnerException); + } + + [TestMethod] + public void TreeCompatibility_WithLegacyTypes_DoesExposeTreeHelpers() + { + CommonTree tree = new CommonTree(new CommonToken(1, "root")); + CommonTokenStream tokens = new CommonTokenStream(new NCalcLexer(new ANTLRStringStream("1 + 2"))); + IToken start = new CommonToken(1, "start"); + IToken stop = new CommonToken(2, "stop"); + Antlr.Runtime.CommonErrorNode errorNode = new Antlr.Runtime.CommonErrorNode( + tokens, + start, + stop, + new RecognitionException(tokens)); + Antlr.Runtime.Tree.UnBufferedTreeNodeStream stream = new Antlr.Runtime.Tree.UnBufferedTreeNodeStream(tree); + + stream.HasUniqueNavigationNodes = true; + + Assert.IsNotNull(Antlr.Runtime.Tree.Tree.INVALID_NODE); + Assert.IsNotNull(errorNode); + Assert.IsTrue(stream.HasUniqueNavigationNodes); + Assert.IsGreaterThanOrEqualTo(0, stream.Size()); + Assert.AreEqual(-1, stream.Index()); + } + + [TestMethod] + public void RewriteRuleElementStream_WithLegacyGenericType_DoesExposeStreamMembers() + { + LegacyRewriteRuleElementStream stream = new LegacyRewriteRuleElementStream(new CommonTreeAdaptor()); + + stream.Add("value"); + + Assert.IsTrue(stream.HasNext()); + Assert.AreEqual(1, stream.Size()); + Assert.AreEqual("value", stream.ReadNext()); + } + + [TestMethod] + public void SynPredPointer_WithLegacyDelegate_DoesInvoke() + { + bool invoked = false; + SynPredPointer synPredPointer = delegate { invoked = true; }; + + synPredPointer(); + + Assert.IsTrue(invoked); + } + + private sealed class LegacyRewriteRuleElementStream : RewriteRuleElementStream + { + public LegacyRewriteRuleElementStream(ITreeAdaptor adaptor) + : base(adaptor, "legacy") + { + } + + public object ReadNext() + { + return _Next(); + } + + protected override object Dup(object element) + { + return element; + } + } + + private sealed class LegacyAntlrFileStream : ANTLRFileStream + { + public long ReadFileLength(FileInfo file) + { + return GetFileLength(file); + } + } + + private static void AssertLegacyTokenValue(int expectedValue, int actualValue) + { + Assert.AreEqual(expectedValue, actualValue); + } + } +} diff --git a/Evaluant.Calculator.Tests/NCalc.Tests.csproj b/Evaluant.Calculator.Tests/NCalc.Tests.csproj index d75ae1e..54c30c3 100644 --- a/Evaluant.Calculator.Tests/NCalc.Tests.csproj +++ b/Evaluant.Calculator.Tests/NCalc.Tests.csproj @@ -8,7 +8,6 @@ - diff --git a/Evaluant.Calculator/AntlrCompatibility.cs b/Evaluant.Calculator/AntlrCompatibility.cs index 049aef5..6fd466f 100644 --- a/Evaluant.Calculator/AntlrCompatibility.cs +++ b/Evaluant.Calculator/AntlrCompatibility.cs @@ -1,10 +1,454 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Text; +using Antlr.Runtime.Tree; + namespace Antlr.Runtime { /// - /// Preserves the non-generic parser return-scope type exposed by the legacy - /// NCalc package while the embedded ANTLR runtime uses the newer generic base. + /// Preserves ANTLR runtime type names that were embedded in the legacy NCalc + /// package and were renamed or removed by the newer ANTLR runtime package. /// - public class ParserRuleReturnScope : ParserRuleReturnScope + public class RuleReturnScope : ParserRuleReturnScope { + public new virtual object Start + { + get { return base.Start; } + set { base.Start = (IToken)value; } + } + + public new virtual object Stop + { + get { return base.Stop; } + set { base.Stop = (IToken)value; } + } + + public virtual object Tree { get; set; } + + public virtual object Template { get { return null; } } + } + + public class ParserRuleReturnScope : RuleReturnScope + { + } + + public static class Token + { + public const int EOR_TOKEN_TYPE = TokenTypes.EndOfRule; + public const int DOWN = TokenTypes.Down; + public const int UP = TokenTypes.Up; + public const int INVALID_TOKEN_TYPE = TokenTypes.Invalid; + public const int DEFAULT_CHANNEL = TokenChannels.Default; + public const int HIDDEN_CHANNEL = TokenChannels.Hidden; + + public static readonly int MIN_TOKEN_TYPE = TokenTypes.Min; + public static readonly int EOF = TokenTypes.EndOfFile; + public static readonly IToken EOF_TOKEN = new CommonToken(EOF); + public static readonly IToken INVALID_TOKEN = new CommonToken(INVALID_TOKEN_TYPE); + public static readonly IToken SKIP_TOKEN = Tokens.Skip; + } + + public class CommonErrorNode : Tree.CommonErrorNode + { + public CommonErrorNode(ITokenStream input, IToken start, IToken stop, RecognitionException e) + : base(input, start, stop, e) + { + } + } + + public delegate void SynPredPointer(); + +#if NETSTANDARD2_0 + public class ANTLRFileStream : ANTLRStringStream + { + private string fileName; + + protected ANTLRFileStream() + { + } + + public ANTLRFileStream(string fileName) + { + Load(fileName, null); + } + + public ANTLRFileStream(string fileName, Encoding encoding) + { + Load(fileName, encoding); + } + + public override string SourceName { get { return fileName; } } + + public virtual void Load(string fileName, Encoding encoding) + { + this.fileName = fileName; + string text = encoding == null ? File.ReadAllText(fileName) : File.ReadAllText(fileName, encoding); + + data = text.ToCharArray(); + n = data.Length; + p = 0; + Line = 1; + CharPositionInLine = 0; + } + + protected virtual long GetFileLength(FileInfo file) + { + return file.Length; + } + } +#endif +} + +namespace Antlr.Runtime.Collections +{ + public class CollectionUtils + { + public static string DictionaryToString(IDictionary dict) + { + if (dict == null) + { + return "null"; + } + + List entries = new List(); + foreach (DictionaryEntry entry in dict) + { + entries.Add(entry.Key + "=" + entry.Value); + } + + return "[" + string.Join(", ", entries.ToArray()) + "]"; + } + + public static string ListToString(IList coll) + { + if (coll == null) + { + return "null"; + } + + List entries = new List(); + foreach (object item in coll) + { + entries.Add(Convert.ToString(item)); + } + + return "[" + string.Join(", ", entries.ToArray()) + "]"; + } + } + + public sealed class HashList : IDictionary + { + private readonly OrderedDictionary values; + + public HashList() + : this(0) + { + } + + public HashList(int capacity) + { + values = new OrderedDictionary(capacity); + } + + public object this[object key] + { + get { return values[key]; } + set { values[key] = value; } + } + + public ICollection Keys { get { return values.Keys; } } + + public ICollection Values { get { return values.Values; } } + + public bool IsReadOnly { get { return values.IsReadOnly; } } + + public bool IsFixedSize { get { return ((IDictionary)values).IsFixedSize; } } + + public int Count { get { return values.Count; } } + + public object SyncRoot { get { return ((ICollection)values).SyncRoot; } } + + public bool IsSynchronized { get { return ((ICollection)values).IsSynchronized; } } + + public void Add(object key, object value) + { + values.Add(key, value); + } + + public void Clear() + { + values.Clear(); + } + + public bool Contains(object key) + { + return values.Contains(key); + } + + public IDictionaryEnumerator GetEnumerator() + { + return values.GetEnumerator(); + } + + public void Remove(object key) + { + values.Remove(key); + } + + public void CopyTo(Array array, int index) + { + ((ICollection)values).CopyTo(array, index); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return values.GetEnumerator(); + } + } + + public class StackList : List + { + public void Push(object item) + { + Add(item); + } + + public object Pop() + { + object item = Peek(); + RemoveAt(Count - 1); + return item; + } + + public object Peek() + { + if (Count == 0) + { + throw new InvalidOperationException("Stack is empty."); + } + + return this[Count - 1]; + } + } +} + +namespace Antlr.Runtime.Misc +{ + public class ErrorManager + { + public static void InternalError(object error, Exception e) + { + throw new InvalidOperationException(Convert.ToString(error), e); + } + + public static void InternalError(object error) + { + throw new InvalidOperationException(Convert.ToString(error)); + } + + public static void Error(object arg) + { + throw new InvalidOperationException(Convert.ToString(arg)); + } + } + + public class Stats + { + public static double Avg(int[] values) + { + return values.Length == 0 ? 0 : (double)Sum(values) / values.Length; + } + + public static int Max(int[] values) + { + if (values.Length == 0) + { + return 0; + } + + int result = values[0]; + foreach (int value in values) + { + result = Math.Max(result, value); + } + + return result; + } + + public static int Min(int[] values) + { + if (values.Length == 0) + { + return 0; + } + + int result = values[0]; + foreach (int value in values) + { + result = Math.Min(result, value); + } + + return result; + } + + public static int Sum(int[] values) + { + int result = 0; + foreach (int value in values) + { + result += value; + } + + return result; + } + + public static double Stddev(int[] values) + { + if (values.Length <= 1) + { + return 0; + } + + double average = Avg(values); + double sumOfSquares = 0; + foreach (int value in values) + { + double difference = value - average; + sumOfSquares += difference * difference; + } + + return Math.Sqrt(sumOfSquares / (values.Length - 1)); + } + + public static string GetAbsoluteFileName(string filename) + { + return Path.GetFullPath(filename); + } + + public static void WriteReport(string filename, string data) + { + File.WriteAllText(GetAbsoluteFileName(filename), data); + } + } +} + +namespace Antlr.Runtime.Tree +{ + public sealed class Tree + { + public static readonly ITree INVALID_NODE = new CommonTree(Antlr.Runtime.Token.INVALID_TOKEN); + } + + public class TreeRuleReturnScope : Antlr.Runtime.RuleReturnScope + { + public override object Start { get; set; } + + public override object Stop { get; set; } + } + + public class UnBufferedTreeNodeStream : BufferedTreeNodeStream + { + private readonly object legacyTreeSource; + + public UnBufferedTreeNodeStream(object tree) + : base(tree) + { + legacyTreeSource = tree; + } + + public UnBufferedTreeNodeStream(ITreeAdaptor adaptor, object tree) + : base(adaptor, tree) + { + legacyTreeSource = tree; + } + + public bool HasUniqueNavigationNodes + { + get { return UniqueNavigationNodes; } + set { UniqueNavigationNodes = value; } + } + + public int Size() + { + return CountTreeNodes(legacyTreeSource); + } + + public new int Index() + { + return base.Index; + } + + private static int CountTreeNodes(object node) + { + ITree tree = node as ITree; + if (tree == null) + { + return node == null ? 0 : 1; + } + + int count = 1; + for (int childIndex = 0; childIndex < tree.ChildCount; childIndex++) + { + count += CountTreeNodes(tree.GetChild(childIndex)); + } + + return count; + } + } + + public abstract class RewriteRuleElementStream : RewriteRuleElementStream + { + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription) + : base(adaptor, elementDescription) + { + } + + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription, T oneElement) + : base(adaptor, elementDescription, oneElement) + { + } + + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription, IList elements) + : base(adaptor, elementDescription, new List(elements)) + { + } + + public RewriteRuleElementStream(ITreeAdaptor adaptor, string elementDescription, IList elements) + : base(adaptor, elementDescription, elements) + { + } + + public void Add(T element) + { + base.Add(element); + } + + public new bool HasNext() + { + return base.HasNext; + } + + public int Size() + { + return Count; + } + + protected object _Next() + { + return NextCore(); + } + + protected virtual object ToTree(T element) + { + return element; + } + + protected override object ToTree(object element) + { + return ToTree((T)element); + } } } diff --git a/Evaluant.Calculator/NCalcParser.cs b/Evaluant.Calculator/NCalcParser.cs index c3f79af..0a5e9de 100644 --- a/Evaluant.Calculator/NCalcParser.cs +++ b/Evaluant.Calculator/NCalcParser.cs @@ -189,7 +189,7 @@ public class ncalcExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -240,14 +240,14 @@ public NCalcParser.ncalcExpression_return ncalcExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -267,7 +267,7 @@ public class logicalExpression_return : ParserRuleReturnScope, IAstRuleReturnSco { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -369,14 +369,14 @@ private NCalcParser.logicalExpression_return logicalExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -396,7 +396,7 @@ public class conditionalExpression_return : ParserRuleReturnScope, IAstRuleRetur { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -510,14 +510,14 @@ private NCalcParser.conditionalExpression_return conditionalExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -537,7 +537,7 @@ public class booleanAndExpression_return : ParserRuleReturnScope, IAstRuleReturn { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -651,14 +651,14 @@ private NCalcParser.booleanAndExpression_return booleanAndExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -678,7 +678,7 @@ public class bitwiseOrExpression_return : ParserRuleReturnScope, IAstRuleReturnS { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -782,14 +782,14 @@ private NCalcParser.bitwiseOrExpression_return bitwiseOrExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -809,7 +809,7 @@ public class bitwiseXOrExpression_return : ParserRuleReturnScope, IAstRuleReturn { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -913,14 +913,14 @@ private NCalcParser.bitwiseXOrExpression_return bitwiseXOrExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -940,7 +940,7 @@ public class bitwiseAndExpression_return : ParserRuleReturnScope, IAstRuleReturn { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -1044,14 +1044,14 @@ private NCalcParser.bitwiseAndExpression_return bitwiseAndExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1071,7 +1071,7 @@ public class equalityExpression_return : ParserRuleReturnScope, IAstRuleReturnSc { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -1247,14 +1247,14 @@ private NCalcParser.equalityExpression_return equalityExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1274,7 +1274,7 @@ public class relationalExpression_return : ParserRuleReturnScope, IAstRuleReturn { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -1476,14 +1476,14 @@ private NCalcParser.relationalExpression_return relationalExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1503,7 +1503,7 @@ public class shiftExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -1659,14 +1659,14 @@ private NCalcParser.shiftExpression_return shiftExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1686,7 +1686,7 @@ public class additiveExpression_return : ParserRuleReturnScope, IAstRuleReturnSc { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -1842,14 +1842,14 @@ private NCalcParser.additiveExpression_return additiveExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -1869,7 +1869,7 @@ public class multiplicativeExpression_return : ParserRuleReturnScope, IAstRuleRe { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -2050,14 +2050,14 @@ private NCalcParser.multiplicativeExpression_return multiplicativeExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2077,7 +2077,7 @@ public class unaryExpression_return : ParserRuleReturnScope, IAstRuleReturnScope { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -2265,14 +2265,14 @@ private NCalcParser.unaryExpression_return unaryExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2292,7 +2292,7 @@ public class primaryExpression_return : ParserRuleReturnScope, IAstRuleReturnSco { public LogicalExpression value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -2463,14 +2463,14 @@ private NCalcParser.primaryExpression_return primaryExpression() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2490,7 +2490,7 @@ public class value_return : ParserRuleReturnScope, IAstRuleReturnScope value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -2924,14 +2924,14 @@ private NCalcParser.expressionList_return expressionList() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally @@ -2951,7 +2951,7 @@ public class arguments_return : ParserRuleReturnScope, IAstRuleReturnScope value; private CommonTree _tree; - public CommonTree Tree { get { return _tree; } set { _tree = value; } } + public new CommonTree Tree { get { return _tree; } set { _tree = value; } } object IAstRuleReturnScope.Tree { get { return Tree; } } } @@ -3040,14 +3040,14 @@ private NCalcParser.arguments_return arguments() retval.Stop = (IToken)input.LT(-1); retval.Tree = (CommonTree)adaptor.RulePostProcessing(root_0); - adaptor.SetTokenBoundaries(retval.Tree, retval.Start, retval.Stop); + adaptor.SetTokenBoundaries(retval.Tree, (IToken)retval.Start, (IToken)retval.Stop); } catch (RecognitionException re) { ReportError(re); Recover(input,re); - retval.Tree = (CommonTree)adaptor.ErrorNode(input, retval.Start, input.LT(-1), re); + retval.Tree = (CommonTree)adaptor.ErrorNode(input, (IToken)retval.Start, input.LT(-1), re); } finally diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh index d93a56d..43afd71 100755 --- a/scripts/consumer-smoke.sh +++ b/scripts/consumer-smoke.sh @@ -78,6 +78,8 @@ add_local_package() { write_smoke_program() { cat >"${SMOKE_ROOT}/NCalc.ConsumerSmoke/Program.cs" <<'CSHARP' using System; +using System.IO; +using System.Text; using Antlr.Runtime; using Antlr.Runtime.Tree; using NCalc; @@ -100,6 +102,25 @@ if (tree is null) throw new InvalidOperationException("Expected parser to return a CommonTree."); } +string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); +try +{ + File.WriteAllText(filePath, "abc", Encoding.UTF8); + ANTLRFileStream fileStream = new ANTLRFileStream(filePath, Encoding.UTF8); + + if (fileStream.SourceName != filePath || fileStream.Count != 3 || fileStream.Substring(0, 3) != "abc") + { + throw new InvalidOperationException("Expected ANTLRFileStream to read a file through the packed NCalc assembly."); + } +} +finally +{ + if (File.Exists(filePath)) + { + File.Delete(filePath); + } +} + Console.WriteLine($"NCalc consumer smoke passed: {result}"); CSHARP } diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index e11f20d..c1c45d1 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -52,6 +52,7 @@ main() { validate_baseline_identity validate_current_identity validate_current_package_shape + validate_antlr_public_surface_compatibility run_net35_parser_source_smoke run_legacy_binary_identity_probe @@ -194,6 +195,50 @@ validate_current_package_shape() { done < <(current_assemblies) } +validate_antlr_public_surface_compatibility() { + local baseline_types + local excluded_types + local current_types + local missing_types + + baseline_types="${WORK_ROOT}/baseline-antlr-public-types.txt" + excluded_types="${WORK_ROOT}/approved-antlr-public-type-exclusions.txt" + public_antlr_types "${BASELINE_EXTRACT_ROOT}/lib/NCalc.dll" >"${baseline_types}" + approved_antlr_public_type_exclusions >"${excluded_types}" + + while IFS= read -r assembly_path; do + current_types="${WORK_ROOT}/current-antlr-public-types-$(basename "$(dirname "${assembly_path}")").txt" + missing_types="${WORK_ROOT}/missing-antlr-public-types-$(basename "$(dirname "${assembly_path}")").txt" + + public_antlr_types "${assembly_path}" >"${current_types}" + comm -23 "${baseline_types}" "${current_types}" | comm -23 - "${excluded_types}" >"${missing_types}" + + if [[ -s "${missing_types}" ]]; then + echo "Current package is missing legacy public ANTLR types in ${assembly_path}:" >&2 + cat "${missing_types}" >&2 + exit 1 + fi + done < <(current_assemblies) +} + +public_antlr_types() { + local assembly_path="$1" + + monodis --typedef "${assembly_path}" | + awk '/^[0-9]+:/ && $2 ~ /^Antlr\.Runtime/ && $0 ~ /flags=0x[0-9A-Fa-f]*[12][,)]/ { print $2 }' | + sort -u +} + +approved_antlr_public_type_exclusions() { + # These two legacy public names are nested inside upstream ANTLR runtime types. + # Restoring them would require vendoring or IL-weaving ANTLR itself; all safe + # top-level legacy names are restored by source shims and protected by smoke tests. + cat <<'TEXT' | sort -u +Antlr.Runtime.DFA/SpecialStateTransitionHandler +Antlr.Runtime.Tree.TreeWizard/ContextVisitor +TEXT +} + assert_package_version_metadata() { local nuspec_path="$1" local nuspec_version @@ -408,9 +453,83 @@ internal static class ParserSmoke throw new InvalidOperationException("Expected parser return scope to expose token boundaries."); } + AssertLegacyAntlrSurface(tokens, parserResult, tree); + Console.WriteLine("Parser smoke passed."); return 0; } + + private static void AssertLegacyAntlrSurface(CommonTokenStream tokens, ParserRuleReturnScope parserResult, CommonTree tree) + { + RuleReturnScope ruleReturnScope = CreateParserReturnScopeFromTree(tree); + TreeRuleReturnScope treeRuleReturnScope = new TreeRuleReturnScope(); + treeRuleReturnScope.Start = tree; + + if (ruleReturnScope.Start == null || treeRuleReturnScope.Start == null) + { + throw new InvalidOperationException("Expected legacy return-scope shims to store Start values."); + } + + if (Token.EOF != -1 || Token.DOWN != 2 || Token.UP != 3 || Token.EOF_TOKEN == null || Token.SKIP_TOKEN == null) + { + throw new InvalidOperationException("Expected legacy Token constants and singleton fields."); + } + + if (Antlr.Runtime.Tree.Tree.INVALID_NODE == null) + { + throw new InvalidOperationException("Expected legacy Tree.INVALID_NODE."); + } + + SynPredPointer synPredPointer = delegate { }; + synPredPointer(); + + Antlr.Runtime.Collections.HashList hashList = new Antlr.Runtime.Collections.HashList(); + hashList.Add("key", "value"); + if (!hashList.Contains("key") || !object.Equals("value", hashList["key"])) + { + throw new InvalidOperationException("Expected legacy HashList behavior."); + } + + Antlr.Runtime.Collections.StackList stackList = new Antlr.Runtime.Collections.StackList(); + stackList.Push("value"); + if (!object.Equals("value", stackList.Peek()) || !object.Equals("value", stackList.Pop())) + { + throw new InvalidOperationException("Expected legacy StackList behavior."); + } + + if (Antlr.Runtime.Collections.CollectionUtils.ListToString(new object[] { "a", "b" }).Length == 0) + { + throw new InvalidOperationException("Expected legacy CollectionUtils behavior."); + } + + if (Antlr.Runtime.Misc.Stats.Sum(new[] { 1, 2, 3 }) != 6) + { + throw new InvalidOperationException("Expected legacy Stats behavior."); + } + + Antlr.Runtime.Misc.ErrorManager errorManager = new Antlr.Runtime.Misc.ErrorManager(); + Antlr.Runtime.CommonErrorNode rootErrorNode = new Antlr.Runtime.CommonErrorNode( + tokens, + (IToken)parserResult.Start, + (IToken)parserResult.Stop, + new RecognitionException(tokens)); + Antlr.Runtime.Tree.UnBufferedTreeNodeStream unbufferedTreeNodeStream = new Antlr.Runtime.Tree.UnBufferedTreeNodeStream(tree); + unbufferedTreeNodeStream.HasUniqueNavigationNodes = true; + + if (errorManager == null || rootErrorNode == null || unbufferedTreeNodeStream.Size() == 0) + { + throw new InvalidOperationException("Expected legacy ANTLR tree shims."); + } + } + + private static RuleReturnScope CreateParserReturnScopeFromTree(CommonTree tree) + { + RuleReturnScope ruleReturnScope = new RuleReturnScope(); + ruleReturnScope.Start = Token.INVALID_TOKEN; + ruleReturnScope.Stop = Token.EOF_TOKEN; + ruleReturnScope.Tree = tree; + return ruleReturnScope; + } } CSHARP } From c3e152d8dc1d9c11e7294ffb00ab012b25d3e908 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 14:04:03 +0100 Subject: [PATCH 16/18] Harden ANTLR file stream compatibility --- .gitignore | 6 + .../AntlrCompatibilityTests.cs | 9 +- Evaluant.Calculator/AntlrCompatibility.cs | 8 +- Evaluant.Calculator/ILRepack.targets | 10 ++ .../NCalc.BuildTools/NCalc.BuildTools.csproj | 14 ++ build/NCalc.BuildTools/Program.cs | 169 ++++++++++++++++++ docs/modernization-release.md | 9 +- docs/quality-gates.md | 4 +- scripts/package-compatibility.sh | 35 ++++ 9 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 build/NCalc.BuildTools/NCalc.BuildTools.csproj create mode 100644 build/NCalc.BuildTools/Program.cs diff --git a/.gitignore b/.gitignore index 6276967..2498ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ [Bb]in/ [Oo]bj/ [Bb]uild/ +!build/ +!build/NCalc.BuildTools/ +!build/NCalc.BuildTools/*.cs +!build/NCalc.BuildTools/*.csproj +build/NCalc.BuildTools/[Bb]in/ +build/NCalc.BuildTools/[Oo]bj/ artifacts/ TestResults/ _Package/ diff --git a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs index f5a9fdb..3e6d248 100644 --- a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs +++ b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs @@ -65,12 +65,11 @@ public void AntlrFileStream_WithUtf8File_DoesLoadTextAndSourceName() Assert.AreEqual(filePath, stream.SourceName); Assert.AreEqual(3, stream.Count); Assert.AreEqual("abc", stream.Substring(0, 3)); - FileInfo fileInfo = new FileInfo(filePath); LegacyAntlrFileStream derivedStream = new LegacyAntlrFileStream(); - Assert.AreEqual(fileInfo.Length, derivedStream.ReadFileLength(fileInfo)); derivedStream.Load(filePath, Encoding.UTF8); - Assert.AreEqual(filePath, derivedStream.SourceName); + Assert.IsNull(derivedStream.SourceName); + Assert.AreEqual(3, derivedStream.Count); } finally { @@ -251,10 +250,6 @@ protected override object Dup(object element) private sealed class LegacyAntlrFileStream : ANTLRFileStream { - public long ReadFileLength(FileInfo file) - { - return GetFileLength(file); - } } private static void AssertLegacyTokenValue(int expectedValue, int actualValue) diff --git a/Evaluant.Calculator/AntlrCompatibility.cs b/Evaluant.Calculator/AntlrCompatibility.cs index 6fd466f..b8b2edc 100644 --- a/Evaluant.Calculator/AntlrCompatibility.cs +++ b/Evaluant.Calculator/AntlrCompatibility.cs @@ -72,11 +72,13 @@ protected ANTLRFileStream() public ANTLRFileStream(string fileName) { + this.fileName = fileName; Load(fileName, null); } public ANTLRFileStream(string fileName, Encoding encoding) { + this.fileName = fileName; Load(fileName, encoding); } @@ -84,7 +86,6 @@ public ANTLRFileStream(string fileName, Encoding encoding) public virtual void Load(string fileName, Encoding encoding) { - this.fileName = fileName; string text = encoding == null ? File.ReadAllText(fileName) : File.ReadAllText(fileName, encoding); data = text.ToCharArray(); @@ -93,11 +94,6 @@ public virtual void Load(string fileName, Encoding encoding) Line = 1; CharPositionInLine = 0; } - - protected virtual long GetFileLength(FileInfo file) - { - return file.Length; - } } #endif } diff --git a/Evaluant.Calculator/ILRepack.targets b/Evaluant.Calculator/ILRepack.targets index 143583d..108401a 100644 --- a/Evaluant.Calculator/ILRepack.targets +++ b/Evaluant.Calculator/ILRepack.targets @@ -3,6 +3,8 @@ $(NuGetPackageRoot)antlr3.runtime/3.5.1/lib/net20/Antlr3.Runtime.dll $(NuGetPackageRoot)antlr3.runtime/3.5.1/lib/netstandard1.1/Antlr3.Runtime.dll + $(MSBuildThisFileDirectory)../build/NCalc.BuildTools/NCalc.BuildTools.csproj + $(MSBuildThisFileDirectory)../build/NCalc.BuildTools/bin/Release/net10.0/NCalc.BuildTools.dll @@ -22,4 +24,12 @@ + + + + + + + + diff --git a/build/NCalc.BuildTools/NCalc.BuildTools.csproj b/build/NCalc.BuildTools/NCalc.BuildTools.csproj new file mode 100644 index 0000000..f858dcf --- /dev/null +++ b/build/NCalc.BuildTools/NCalc.BuildTools.csproj @@ -0,0 +1,14 @@ + + + net10.0 + NCalc.BuildTools + NCalc.BuildTools + Exe + disable + disable + + + + + + diff --git a/build/NCalc.BuildTools/Program.cs b/build/NCalc.BuildTools/Program.cs new file mode 100644 index 0000000..a302f9c --- /dev/null +++ b/build/NCalc.BuildTools/Program.cs @@ -0,0 +1,169 @@ +using System; +using System.IO; +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace NCalc.BuildTools +{ + /// + /// Hosts build-time assembly patching that preserves legacy public surface + /// after third-party assemblies are merged into the NCalc package. + /// + internal static class Program + { + private const string RestoreNet35AntlrFileStreamCommand = "restore-net35-antlr-filestream"; + private const string AntlrFileStreamTypeName = "Antlr.Runtime.ANTLRFileStream"; + + private static int Main(string[] args) + { + try + { + if (args.Length < 2 || args[0] != RestoreNet35AntlrFileStreamCommand) + { + WriteUsage(); + return 2; + } + + string assemblyPath = args[1]; + string strongNameKeyPath = args.Length >= 3 ? args[2] : string.Empty; + + RestoreNet35AntlrFileStreamCompatibility(assemblyPath, strongNameKeyPath); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception.Message); + return 1; + } + } + + private static void RestoreNet35AntlrFileStreamCompatibility(string assemblyPath, string strongNameKeyPath) + { + if (!File.Exists(assemblyPath)) + { + throw new FileNotFoundException("Assembly to patch was not found.", assemblyPath); + } + + string symbolsPath = Path.ChangeExtension(assemblyPath, ".pdb"); + bool readSymbols = File.Exists(symbolsPath); + ReaderParameters readerParameters = new ReaderParameters + { + ReadSymbols = readSymbols, + ReadingMode = ReadingMode.Immediate + }; + + string temporaryPath; + using (AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(assemblyPath, readerParameters)) + { + ModuleDefinition module = assembly.MainModule; + TypeDefinition antlrFileStream = module.Types.FirstOrDefault(type => type.FullName == AntlrFileStreamTypeName); + + if (antlrFileStream == null) + { + throw new InvalidOperationException("Could not find " + AntlrFileStreamTypeName + " in " + assemblyPath + "."); + } + + bool changed = false; + changed |= EnsureProtectedDefaultConstructor(module, antlrFileStream); + + if (!changed) + { + Console.WriteLine("ANTLRFileStream legacy members already present."); + return; + } + + temporaryPath = WriteAssemblyToTemporaryFile(assembly, assemblyPath, strongNameKeyPath, readSymbols); + } + + ReplaceAssemblyWithTemporaryOutput(assemblyPath, temporaryPath, readSymbols); + Console.WriteLine("Restored net35 ANTLRFileStream legacy members."); + } + + private static bool EnsureProtectedDefaultConstructor(ModuleDefinition module, TypeDefinition antlrFileStream) + { + bool hasDefaultConstructor = antlrFileStream.Methods.Any(method => method.IsConstructor && !method.HasParameters); + if (hasDefaultConstructor) + { + return false; + } + + MethodReference baseConstructor = ImportDefaultBaseConstructor(module, antlrFileStream); + MethodDefinition constructor = new MethodDefinition( + ".ctor", + MethodAttributes.Family | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, + module.TypeSystem.Void); + + ILProcessor ilProcessor = constructor.Body.GetILProcessor(); + ilProcessor.Append(ilProcessor.Create(OpCodes.Ldarg_0)); + ilProcessor.Append(ilProcessor.Create(OpCodes.Call, baseConstructor)); + ilProcessor.Append(ilProcessor.Create(OpCodes.Ret)); + + antlrFileStream.Methods.Add(constructor); + return true; + } + + private static MethodReference ImportDefaultBaseConstructor(ModuleDefinition module, TypeDefinition typeDefinition) + { + TypeDefinition baseType = typeDefinition.BaseType.Resolve(); + MethodDefinition baseConstructor = baseType.Methods.FirstOrDefault(method => method.IsConstructor && !method.HasParameters); + + if (baseConstructor == null) + { + throw new InvalidOperationException(typeDefinition.BaseType.FullName + " does not expose a default constructor."); + } + + return module.ImportReference(baseConstructor); + } + + private static string WriteAssemblyToTemporaryFile( + AssemblyDefinition assembly, + string assemblyPath, + string strongNameKeyPath, + bool writeSymbols) + { + string temporaryPath = assemblyPath + ".patched"; + WriterParameters writerParameters = new WriterParameters { WriteSymbols = writeSymbols }; + + if (!string.IsNullOrWhiteSpace(strongNameKeyPath)) + { + writerParameters.StrongNameKeyBlob = File.ReadAllBytes(strongNameKeyPath); + } + + assembly.Write(temporaryPath, writerParameters); + return temporaryPath; + } + + private static void ReplaceAssemblyWithTemporaryOutput(string assemblyPath, string temporaryPath, bool writeSymbols) + { + File.Copy(temporaryPath, assemblyPath, true); + File.Delete(temporaryPath); + + if (!writeSymbols) + { + return; + } + + string originalSymbolsPath = Path.ChangeExtension(assemblyPath, ".pdb"); + string assemblyNameSidecarSymbolsPath = assemblyPath + ".pdb"; + string temporarySymbolsPath = temporaryPath + ".pdb"; + + if (File.Exists(assemblyNameSidecarSymbolsPath)) + { + File.Copy(assemblyNameSidecarSymbolsPath, originalSymbolsPath, true); + File.Delete(assemblyNameSidecarSymbolsPath); + } + + if (File.Exists(temporarySymbolsPath)) + { + File.Copy(temporarySymbolsPath, originalSymbolsPath, true); + File.Delete(temporarySymbolsPath); + } + } + + private static void WriteUsage() + { + Console.Error.WriteLine("Usage: NCalc.BuildTools restore-net35-antlr-filestream [strong-name-key-path]"); + } + } +} diff --git a/docs/modernization-release.md b/docs/modernization-release.md index 28e95a2..adacf3a 100644 --- a/docs/modernization-release.md +++ b/docs/modernization-release.md @@ -1,6 +1,6 @@ # Modernization Release Notes -This branch is the release-infrastructure lane for the NCalc modernization stack. It is intended to land after the SDK-style .NET Standard 2.0 project conversion and the modern test-project conversion. +These notes describe the release infrastructure for the NCalc modernization stack. They assume the SDK-style .NET Standard 2.0 project conversion, modern test-project conversion, expanded characterization coverage, and compatibility gates are part of the release line. ## Stack Order @@ -8,7 +8,8 @@ This branch is the release-infrastructure lane for the NCalc modernization stack 2. Port the test project to a modern SDK-style test runner. 3. Expand characterization coverage while preserving existing behavior. 4. Add numeric edge-case and API-surface coverage. -5. Add this build, package, and release infrastructure. +5. Add build, package, and release infrastructure. +6. Harden package compatibility for the embedded ANTLR surface and major-version strong-name identity change. ## Local Validation @@ -39,7 +40,7 @@ Release infrastructure changes should not be treated as passing until the branch - successful workflow YAML parsing or equivalent linting; - successful local validation for commands available on the current stack; - explicit documentation of any stack dependency that prevents full validation; -- no runtime-source changes in this release-infrastructure lane. +- runtime-source and build-tool changes are documented, compatibility-focused, and covered by validation gates. ## Package Identity @@ -78,7 +79,7 @@ For non-release CI, the quality-gates workflow can generate an ephemeral key whe ## Package Validation And Restore Locking -Package validation is enabled in the release gate against the `NCalc` `1.3.8` baseline. The package compatibility script adds checks that SDK package validation does not cover, including strong-name token, assembly version, package major version, license metadata, embedded ANTLR surface, and parser/lexer consumer smoke for legacy source patterns such as non-generic `ParserRuleReturnScope`. +Package validation is enabled in the release gate against the `NCalc` `1.3.8` baseline. The package compatibility script adds checks that SDK package validation does not cover, including strong-name token, assembly version, package major version, license metadata, embedded ANTLR surface, and parser/lexer consumer smoke for legacy source patterns such as non-generic `ParserRuleReturnScope` and derived `ANTLRFileStream` consumers. Restore lock files are still intentionally deferred. Generate and review `packages.lock.json` only after the dependency set has settled; do not check in a hand-written lock file. diff --git a/docs/quality-gates.md b/docs/quality-gates.md index b6f4d55..55ba8ac 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -1,6 +1,6 @@ # Quality Gates -This branch adds the repeatable validation gates for the NCalc modernization stack. It is designed to run after the SDK-style core port and the modern test-project migration. +These validation gates keep the NCalc modernization stack honest. They run after the SDK-style core port, modern test-project migration, and compatibility hardening have been applied. ## Passing Standard @@ -124,7 +124,7 @@ It verifies: - the current package declares license expression `MIT`; - `Antlr3.Runtime` is not a package dependency; - the `net35` and `netstandard2.0` assemblies embed public `Antlr.Runtime.*`, `NCalcLexer`, `NCalcParser`, and parser return-scope types; -- a parser/lexer consumer using the legacy non-generic `ParserRuleReturnScope` source pattern can compile and run against the packed `net35` assembly; +- parser/lexer consumers using legacy ANTLR source patterns, including non-generic `ParserRuleReturnScope` and derived `ANTLRFileStream`, can compile and run against the packed `net35` assembly; - a consumer compiled against the legacy package references the old token, documenting why a new key is a major-version binary identity break. ## Consumer Smoke diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index c1c45d1..f2e0b4c 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -422,6 +422,7 @@ write_parser_smoke_program() { cat >"${program_path}" <<'CSHARP' using System; +using System.IO; using Antlr.Runtime; using Antlr.Runtime.Tree; using NCalc; @@ -507,6 +508,8 @@ internal static class ParserSmoke throw new InvalidOperationException("Expected legacy Stats behavior."); } + AssertLegacyAntlrFileStreamSurface(); + Antlr.Runtime.Misc.ErrorManager errorManager = new Antlr.Runtime.Misc.ErrorManager(); Antlr.Runtime.CommonErrorNode rootErrorNode = new Antlr.Runtime.CommonErrorNode( tokens, @@ -530,6 +533,38 @@ internal static class ParserSmoke ruleReturnScope.Tree = tree; return ruleReturnScope; } + + private static void AssertLegacyAntlrFileStreamSurface() + { + string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + + try + { + File.WriteAllText(filePath, "abc"); + LegacyAntlrFileStream fileStream = new LegacyAntlrFileStream(); + fileStream.Load(filePath, null); + + if (fileStream.Count != 3) + { + throw new InvalidOperationException("Expected legacy ANTLRFileStream protected members."); + } + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + private sealed class LegacyAntlrFileStream : ANTLRFileStream + { + public LegacyAntlrFileStream() + : base() + { + } + } } CSHARP } From c101c88f3133eaec33d4c32294c246af985c3af7 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 14:09:58 +0100 Subject: [PATCH 17/18] Preserve ANTLRFileStream protected state --- Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs | 5 +++++ Evaluant.Calculator/AntlrCompatibility.cs | 2 +- scripts/package-compatibility.sh | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs index 3e6d248..92ac5ef 100644 --- a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs +++ b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs @@ -69,6 +69,7 @@ public void AntlrFileStream_WithUtf8File_DoesLoadTextAndSourceName() derivedStream.Load(filePath, Encoding.UTF8); Assert.IsNull(derivedStream.SourceName); + Assert.IsNull(derivedStream.FileName); Assert.AreEqual(3, derivedStream.Count); } finally @@ -250,6 +251,10 @@ protected override object Dup(object element) private sealed class LegacyAntlrFileStream : ANTLRFileStream { + public string FileName + { + get { return fileName; } + } } private static void AssertLegacyTokenValue(int expectedValue, int actualValue) diff --git a/Evaluant.Calculator/AntlrCompatibility.cs b/Evaluant.Calculator/AntlrCompatibility.cs index b8b2edc..4e8b0f3 100644 --- a/Evaluant.Calculator/AntlrCompatibility.cs +++ b/Evaluant.Calculator/AntlrCompatibility.cs @@ -64,7 +64,7 @@ public CommonErrorNode(ITokenStream input, IToken start, IToken stop, Recognitio #if NETSTANDARD2_0 public class ANTLRFileStream : ANTLRStringStream { - private string fileName; + protected string fileName; protected ANTLRFileStream() { diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index f2e0b4c..db90ede 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -544,7 +544,7 @@ internal static class ParserSmoke LegacyAntlrFileStream fileStream = new LegacyAntlrFileStream(); fileStream.Load(filePath, null); - if (fileStream.Count != 3) + if (fileStream.Count != 3 || fileStream.FileName != null) { throw new InvalidOperationException("Expected legacy ANTLRFileStream protected members."); } @@ -564,6 +564,11 @@ internal static class ParserSmoke : base() { } + + public string FileName + { + get { return fileName; } + } } } CSHARP From 57a15945e6df6b2eb8d204e67fb167e74906fa70 Mon Sep 17 00:00:00 2001 From: James Cane Date: Sat, 20 Jun 2026 14:21:14 +0100 Subject: [PATCH 18/18] Restore legacy ANTLR stream members --- .../AntlrCompatibilityTests.cs | 13 ++-- Evaluant.Calculator/AntlrCompatibility.cs | 78 +++++++++++++++++-- scripts/package-compatibility.sh | 51 ++++++++++++ 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs index 92ac5ef..82bd143 100644 --- a/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs +++ b/Evaluant.Calculator.Tests/AntlrCompatibilityTests.cs @@ -206,6 +206,14 @@ public void TreeCompatibility_WithLegacyTypes_DoesExposeTreeHelpers() Assert.IsTrue(stream.HasUniqueNavigationNodes); Assert.IsGreaterThanOrEqualTo(0, stream.Size()); Assert.AreEqual(-1, stream.Index()); + Assert.Throws(delegate { stream.Get(0); }); + Assert.IsNull(stream.Current); + Assert.IsTrue(stream.MoveNext()); + Assert.AreSame(tree, stream.Current); + Assert.IsFalse(stream.MoveNext()); + Assert.IsNull(stream.Current); + stream.Reset(); + Assert.IsNull(stream.Current); } [TestMethod] @@ -242,11 +250,6 @@ public object ReadNext() { return _Next(); } - - protected override object Dup(object element) - { - return element; - } } private sealed class LegacyAntlrFileStream : ANTLRFileStream diff --git a/Evaluant.Calculator/AntlrCompatibility.cs b/Evaluant.Calculator/AntlrCompatibility.cs index 4e8b0f3..3697c6d 100644 --- a/Evaluant.Calculator/AntlrCompatibility.cs +++ b/Evaluant.Calculator/AntlrCompatibility.cs @@ -347,18 +347,19 @@ public class TreeRuleReturnScope : Antlr.Runtime.RuleReturnScope public class UnBufferedTreeNodeStream : BufferedTreeNodeStream { - private readonly object legacyTreeSource; + private readonly List legacyTraversal = new List(); + private int legacyTraversalIndex = -1; public UnBufferedTreeNodeStream(object tree) : base(tree) { - legacyTreeSource = tree; + AddTraversalNodes(tree); } public UnBufferedTreeNodeStream(ITreeAdaptor adaptor, object tree) : base(adaptor, tree) { - legacyTreeSource = tree; + AddTraversalNodes(tree); } public bool HasUniqueNavigationNodes @@ -367,9 +368,36 @@ public bool HasUniqueNavigationNodes set { UniqueNavigationNodes = value; } } + public object Current { get; private set; } + + public object Get(int index) + { + throw new NotSupportedException("stream is unbuffered"); + } + + public bool MoveNext() + { + if (legacyTraversalIndex + 1 >= legacyTraversal.Count) + { + Current = null; + return false; + } + + legacyTraversalIndex++; + Current = legacyTraversal[legacyTraversalIndex]; + return true; + } + + public override void Reset() + { + base.Reset(); + legacyTraversalIndex = -1; + Current = null; + } + public int Size() { - return CountTreeNodes(legacyTreeSource); + return CountStreamItems(root); } public new int Index() @@ -377,18 +405,49 @@ public int Size() return base.Index; } - private static int CountTreeNodes(object node) + private void AddTraversalNodes(object node) { + if (node == null) + { + return; + } + + legacyTraversal.Add(node); + ITree tree = node as ITree; if (tree == null) { - return node == null ? 0 : 1; + return; + } + + for (int childIndex = 0; childIndex < tree.ChildCount; childIndex++) + { + AddTraversalNodes(tree.GetChild(childIndex)); + } + } + + private static int CountStreamItems(object node) + { + if (node == null) + { + return 0; + } + + ITree tree = node as ITree; + if (tree == null) + { + return 1; } int count = 1; + if (tree.ChildCount > 0) + { + count += 2; + } + for (int childIndex = 0; childIndex < tree.ChildCount; childIndex++) { - count += CountTreeNodes(tree.GetChild(childIndex)); + count += CountStreamItems(tree.GetChild(childIndex)); } return count; @@ -437,6 +496,11 @@ protected object _Next() return NextCore(); } + protected override object Dup(object element) + { + return element; + } + protected virtual object ToTree(T element) { return element; diff --git a/scripts/package-compatibility.sh b/scripts/package-compatibility.sh index db90ede..bd98d6a 100755 --- a/scripts/package-compatibility.sh +++ b/scripts/package-compatibility.sh @@ -519,6 +519,9 @@ internal static class ParserSmoke Antlr.Runtime.Tree.UnBufferedTreeNodeStream unbufferedTreeNodeStream = new Antlr.Runtime.Tree.UnBufferedTreeNodeStream(tree); unbufferedTreeNodeStream.HasUniqueNavigationNodes = true; + AssertLegacyUnBufferedTreeNodeStreamSurface(unbufferedTreeNodeStream); + AssertLegacyRewriteRuleElementStreamSurface(); + if (errorManager == null || rootErrorNode == null || unbufferedTreeNodeStream.Size() == 0) { throw new InvalidOperationException("Expected legacy ANTLR tree shims."); @@ -534,6 +537,41 @@ internal static class ParserSmoke return ruleReturnScope; } + private static void AssertLegacyUnBufferedTreeNodeStreamSurface(UnBufferedTreeNodeStream stream) + { + bool getThrowsNotSupported = false; + try + { + stream.Get(0); + } + catch (NotSupportedException) + { + getThrowsNotSupported = true; + } + + if (!getThrowsNotSupported || stream.Current != null || !stream.MoveNext() || stream.Current == null) + { + throw new InvalidOperationException("Expected legacy UnBufferedTreeNodeStream cursor members."); + } + + stream.Reset(); + if (stream.Current != null) + { + throw new InvalidOperationException("Expected legacy UnBufferedTreeNodeStream reset behavior."); + } + } + + private static void AssertLegacyRewriteRuleElementStreamSurface() + { + LegacyRewriteRuleElementStream stream = new LegacyRewriteRuleElementStream(new CommonTreeAdaptor()); + stream.Add("value"); + + if (!stream.HasNext() || stream.Size() != 1 || !object.Equals("value", stream.ReadNext())) + { + throw new InvalidOperationException("Expected legacy RewriteRuleElementStream subclass behavior."); + } + } + private static void AssertLegacyAntlrFileStreamSurface() { string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); @@ -570,6 +608,19 @@ internal static class ParserSmoke get { return fileName; } } } + + private sealed class LegacyRewriteRuleElementStream : RewriteRuleElementStream + { + public LegacyRewriteRuleElementStream(ITreeAdaptor adaptor) + : base(adaptor, "legacy") + { + } + + public object ReadNext() + { + return _Next(); + } + } } CSHARP }