From 2f5c930c63b4d3aaf1ac42df1ff130423bc8cfd1 Mon Sep 17 00:00:00 2001 From: Neo Date: Mon, 29 Jun 2026 13:14:47 +0000 Subject: [PATCH] fix: [SD_CFX_002] add isNotPresent/isPresent suffix unary operators to LiquidJS evalExp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These operators are used in Liquid computation text (common_cf_text) across templates such as template 27188 (Metergy Solutions, Workspace 353009). They were already supported in docx-templater/expressions.js (Angular Expressions) but were silently unsupported in the LiquidJS computation engine, causing: RenderError: cannot eval 'sd_thermal_risk_type isNotPresent' as value, line:53 This only manifested at runtime when the guard condition resolving to a code path containing the isNotPresent expression evaluated to TRUE (e.g. Electricity Risk Type = Common Area), returning HTTP 400 from cfexporter and SD_CFX_002 to the UI. Changes: - evalExp: detect suffix unary operators before falling through to evalValue - validateExpression: same — prevents false validation failures - SUFFIX_UNARY_OPERATORS map: semantics aligned with docx-templater isPresent/isNotPresent (null | undefined | false → not present; anything else → present) - test/syntax.js: 10 new tests covering null, undefined, false, string, empty-string cases Fixes: SD_CFX_002 generation failure for contracts with Electricity Risk Type = Common Area Co-authored-by: Anjali Swami --- src/syntax.js | 43 +++++++++++++++++++++++++++++++++++++++++ test/syntax.js | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/syntax.js b/src/syntax.js index 79f7c6e75e..cd263d943d 100644 --- a/src/syntax.js +++ b/src/syntax.js @@ -2,6 +2,26 @@ const operators = require('./operators.js')(isTruthy) const lexical = require('./lexical.js') const assert = require('../src/util/assert.js') +/** + * Suffix unary operators that can be used in {% if %} expressions. + * + * These mirror the same-named filters in docx-templater/expressions.js so + * that template authors can use identical syntax in both DOCX template tags + * and Liquid computation text (common_cf_text). + * + * Semantics: + * isNotPresent – true when the variable is null, undefined, or false + * isPresent – true when the variable is NOT null, undefined, or false + * + * Usage in templates: + * {% if sd_thermal_risk_type isNotPresent %} + * {% if sd_some_field isPresent %} + */ +var SUFFIX_UNARY_OPERATORS = { + isNotPresent: function (v) { return v === false || v === undefined || v === null }, + isPresent: function (v) { return v !== false && v !== undefined && v !== null } +} + function evalExp (exp, scope) { assert(scope, 'unable to evalExp: scope undefined') var operatorREs = lexical.operators @@ -27,6 +47,17 @@ function evalExp (exp, scope) { return range } + // Handle suffix unary operators: isNotPresent, isPresent + // e.g. "sd_thermal_risk_type isNotPresent" + var trimmedExp = exp.trim() + for (var opName in SUFFIX_UNARY_OPERATORS) { + if (trimmedExp.endsWith(' ' + opName)) { + var operandStr = trimmedExp.slice(0, -(opName.length + 1)).trim() + var val = evalValue(operandStr, scope) + return SUFFIX_UNARY_OPERATORS[opName](val) + } + } + return evalValue(exp, scope) } @@ -63,6 +94,18 @@ function validateExpression(exp, scope, errors = []) { return errors; } } + + // Handle suffix unary operators in validation + var trimmedExp = exp.trim() + for (var opName in SUFFIX_UNARY_OPERATORS) { + if (trimmedExp.endsWith(' ' + opName)) { + var operandStr = trimmedExp.slice(0, -(opName.length + 1)).trim() + var valError = validateValue(operandStr, scope) + if (valError) errors.push(valError) + return errors; + } + } + if((error = validateValue(exp, scope))) { errors.push(error); } diff --git a/test/syntax.js b/test/syntax.js index cf0dab00d6..7d1b145a61 100644 --- a/test/syntax.js +++ b/test/syntax.js @@ -114,3 +114,55 @@ describe('expression', function () { }) }) }) + +describe('suffix unary operators', function () { + var scope + + beforeEach(function () { + scope = Scope.factory({ + present_val: 'Common Area', + falsy_val: false, + empty_str: '' + }) + }) + + describe('isNotPresent', function () { + it('should return true when variable is null', function () { + var s = Scope.factory({ v: null }) + expect(evalExp('v isNotPresent', s)).to.equal(true) + }) + it('should return true when variable is undefined', function () { + var s = Scope.factory({}) + expect(evalExp('v isNotPresent', s)).to.equal(true) + }) + it('should return true when variable is false', function () { + expect(evalExp('falsy_val isNotPresent', scope)).to.equal(true) + }) + it('should return false when variable has a string value', function () { + expect(evalExp('present_val isNotPresent', scope)).to.equal(false) + }) + it('should return false when variable is empty string', function () { + expect(evalExp('empty_str isNotPresent', scope)).to.equal(false) + }) + }) + + describe('isPresent', function () { + it('should return false when variable is null', function () { + var s = Scope.factory({ v: null }) + expect(evalExp('v isPresent', s)).to.equal(false) + }) + it('should return false when variable is undefined', function () { + var s = Scope.factory({}) + expect(evalExp('v isPresent', s)).to.equal(false) + }) + it('should return false when variable is false', function () { + expect(evalExp('falsy_val isPresent', scope)).to.equal(false) + }) + it('should return true when variable has a string value', function () { + expect(evalExp('present_val isPresent', scope)).to.equal(true) + }) + it('should return true when variable is empty string', function () { + expect(evalExp('empty_str isPresent', scope)).to.equal(true) + }) + }) +})