Skip to content

fix: draft() emits ciceromark Formula node without required value field (#146)#149

Open
mttrbrts wants to merge 3 commits into
mainfrom
fix/146-formula-missing-value
Open

fix: draft() emits ciceromark Formula node without required value field (#146)#149
mttrbrts wants to merge 3 commits into
mainfrom
fix/146-formula-missing-value

Conversation

@mttrbrts
Copy link
Copy Markdown
Member

Fixes #146.

Root cause

A formula written as a bare expression — e.g. {{% importerLOCAmount / 2.0 %}} without an explicit return — was compiled by writeFunctionToString into a TS function whose body was an expression statement:

export function formula_xxx(data, library, options) : any {
    const importerLOCAmount = data.importerLOCAmount;
    // ...
    importerLOCAmount / 2.0   // expression statement — discarded, returns undefined
}

That function returns undefined. In evaluateUserCode the result was then stored as JSON.stringify(undefined) === undefined. In the visitor that writes value onto the Formula node, neither the result === null branch nor the typeof result === 'string' branch matched, so the fallback context.value = JSON.stringify(result) also produced undefined. context.code was then deleted, leaving the Formula node with name + dependencies but no value. validateCiceroMark then rejected the document with:

ValidationException: The instance "org.accordproject.ciceromark@0.6.0.Formula" is missing the required field "value"

This reproduces with the supply-agreement-loc template in cicero-template-library PR #484 (versions: template-engine 3.0.1, cicero-core 0.26.0, concerto-core 4.1.0).

Fix

  • src/utils.ts: writeFunctionToString now wraps the user's code with return when no explicit return keyword is present at the source level. Strings and comments are stripped before the keyword scan to avoid false positives (e.g. a literal 'return foo' inside the user's code). This restores the implicit-expression semantics that Cicero/Ergo formulas have always had — {{% amount / 2.0 %}} again produces a value. Existing formulas that already use return are unchanged.
  • src/TemplateMarkInterpreter.ts: as defense in depth, the formula visitor in generateAgreement now throws a clear, named error if the formula's evaluation result is still undefined, instead of producing an invalid ciceromark document and surfacing the confusing missing the required field "value" message from the validator.

Test

Adds test/templates/good/helloformula-implicit-return/ — a minimal template containing the inline-formula repro {{% importerLOCAmount / 2.0 %}}. The matching snapshot asserts the produced org.accordproject.ciceromark@0.6.0.Formula node has "value": "500". Sandbox (child-process) evaluation is also covered by the existing harness, so both code paths are exercised.

The formula-invalid snapshot was regenerated: the test still rejects garbage input — only the reported character offsets shift by the length of the prepended return token. No behavior change.

Scope

Limited to accordproject/template-engine. No changes to cicero-core, ciceromark model, concerto, or other dependencies.

Test plan

  • npx jest TemplateMarkInterpreter -t formula — all 6 formula tests pass (helloformula, helloformula-implicit-return, formula-now, formula-invalid, formula-no-method, formula-optional-noguard)
  • npm run lint clean (no new warnings)
  • npm run licchk clean
  • Full npm test — remaining 7 failures are pre-existing 403 errors fetching external models (network sandbox) and a pre-existing dynamic-import-callback issue in TemplateArchiveProcessor.test; all confirmed unchanged vs. main baseline.

Generated by Claude Code

Comment thread src/utils.ts Fixed
@coveralls
Copy link
Copy Markdown

coveralls commented May 16, 2026

Coverage Report for CI Build 25955862016

Coverage increased (+0.4%) to 64.606%

Details

  • Coverage increased (+0.4%) from the base build.
  • Patch coverage: 2 uncovered changes across 2 files (21 of 23 lines covered, 91.3%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
src/TemplateMarkInterpreter.ts 3 2 66.67%
src/utils.ts 20 19 95.0%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
src/TemplateMarkInterpreter.ts 1 84.26%

Coverage Stats

Coverage Status
Relevant Lines: 1153
Covered Lines: 742
Line Coverage: 64.35%
Relevant Branches: 562
Covered Branches: 366
Branch Coverage: 65.12%
Branches in Coverage %: Yes
Coverage Strength: 5686.03 hits per line

💛 - Coveralls

Copy link
Copy Markdown
Member Author

Follow-up push: swapped the regex-on-source detection for an AST-based check.

What changed

  • wrapExpressionWithReturn now parses the user code with ts.createSourceFile(...) and inspects only sourceFile.statements (top-level only).
    • Any top-level ReturnStatement → code is kept verbatim.
    • Last top-level statement is an ExpressionStatement → emit preceding statements verbatim followed by return <expr>;.
    • Otherwise → leave unchanged; the existing "did not return a value" runtime error in TemplateMarkInterpreter.ts fires as before.
  • Deleted stripStringsAndComments and RETURN_KEYWORD_RE. typescript is already a direct dependency, so no package.json changes were needed.

Why
The previous regex matched any return keyword anywhere in the source (after stripping strings/comments). That triggered a false positive when a nested function or arrow contained its own return — e.g. [1,2,3].map(x => { return x*2 })[0]. The outer expression was treated as "already returns" and never wrapped, so the resulting Formula node was missing its required value field — the same shape of bug this PR was originally fixing, just via a different route. Walking only top-level statements via the TS AST eliminates that class of false positive.

Tests added (test/utils.test.ts)

  • bare expression → wrapped
  • explicit top-level return → unchanged
  • nested arrow with return, outer is an expression → wrapped (the regression case)
  • multi-statement body with trailing expression → wraps only the trailing expression
  • statements-only body (no trailing expression) → unchanged
  • empty / whitespace-only → unchanged

Snapshot update
The formula-invalid snapshot was regenerated. The diff is purely positional for the three semantic Cannot find name 'THIS'/'IS'/'GARBAGE' errors, plus the disappearance of a spurious ';' expected. syntax error — the new wrapping shape (THIS\nIS\nreturn GARBAGE;) is itself syntactically valid, so the TS compiler no longer reports a syntax error on top of the identifier errors. No semantic change in the failure mode.


Generated by Claude Code

claude added 2 commits May 16, 2026 07:09
…ld (#146)

An inline formula written as a bare expression — e.g. `{{% amount / 2.0 %}}`
without `return` — was compiled to a TS function whose body was an expression
statement, so the function returned undefined. JSON.stringify(undefined) is
undefined, which left the `value` field unset on the ciceromark Formula node
and caused the post-generation validator to reject the document with
`ValidationException: missing the required field "value"`.

Fix: `writeFunctionToString` now wraps user code with `return` when no
explicit `return` keyword is present (strings/comments stripped to avoid
false positives), restoring the implicit-expression semantics that
Cicero/Ergo formulas have always had. As a defense in depth, the formula
visitor in `generateAgreement` now throws a clear error if it ever still
sees an undefined result, instead of producing an invalid document.

Adds a `helloformula-implicit-return` good-template test that reproduces
the bug and asserts the Formula node carries the evaluated `value`.

Signed-off-by: Claude <noreply@anthropic.com>
CodeQL flagged the block-comment regex `/\/\*[\s\S]*?\*\//g` (and the
nested-quantifier string-literal regex) as polynomial on uncontrolled
input. The formula/condition source text comes from user-authored
templates and is uncontrolled, so the alert is real.

Replace the three regex-based replace calls with a single O(n)
char-by-char scanner that drops line comments, block comments, and
string literals. Functionally equivalent for the existing test cases
and immune to ReDoS by construction.

Signed-off-by: Claude <noreply@anthropic.com>
@mttrbrts mttrbrts force-pushed the fix/146-formula-missing-value branch from a0c6782 to fc7364b Compare May 16, 2026 07:11
Replace the regex-on-source approach in wrapExpressionWithReturn with an
AST walk over top-level statements via ts.createSourceFile. The regex
matched any `return` keyword anywhere in the user code, which gave a
false positive when a nested function/arrow contained its own return
(e.g. `[1,2,3].map(x => { return x*2 })[0]`) — the outer expression was
then never wrapped and the Formula node ended up missing its `value`.

The new logic inspects only sourceFile.statements: if any top-level
statement is a ReturnStatement the code is kept verbatim; if the last
top-level statement is an ExpressionStatement it is rewritten as
`return <expr>;`; otherwise the code is left unchanged and the existing
"did not return a value" runtime error fires.

The stripStringsAndComments helper and RETURN_KEYWORD_RE constant are
removed. typescript is already a direct dependency so no package.json
changes were needed.

Adds focused unit tests for wrapExpressionWithReturn (bare expression,
explicit return, nested-return regression, multi-statement trailing
expression, statements-only, empty input). The formula-invalid snapshot
is regenerated: only the character offsets shift and a spurious
"';' expected." syntax error disappears because the new wrapping shape
is syntactically valid; the semantic "Cannot find name" errors are
unchanged.

Signed-off-by: Claude <noreply@anthropic.com>
@mttrbrts mttrbrts marked this pull request as ready for review May 16, 2026 07:21
@mttrbrts mttrbrts requested review from a team and Copilot May 16, 2026 07:21
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes implicit formula expressions so inline {{% expr %}} formulas produce a ciceromark Formula.value instead of undefined, while adding a clearer guard for formulas that still do not return a value.

Changes:

  • Adds wrapExpressionWithReturn and applies it when generating user-code functions.
  • Adds an undefined-result guard for formula evaluation.
  • Adds a regression template/snapshot and unit coverage for expression wrapping.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/utils.ts Adds formula/condition wrapping logic before generated function output.
src/TemplateMarkInterpreter.ts Adds explicit error handling for undefined formula results.
test/utils.test.ts Adds unit tests for expression wrapping behavior.
test/templates/good/helloformula-implicit-return/template.md Adds regression fixture for implicit formula return.
test/templates/good/helloformula-implicit-return/model.cto Adds model for the regression fixture.
test/templates/good/helloformula-implicit-return/data.json Adds data for the regression fixture.
test/__snapshots__/TemplateMarkInterpreter.test.ts.snap Updates formula snapshots, including the new implicit-return fixture.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utils.ts

import { existsSync, mkdirSync, rmSync } from 'fs';
import traverse from 'traverse';
import * as ts from 'typescript';
Comment thread src/utils.ts
if (trimmed.length === 0) {
return trimmed;
}
const sourceFile = ts.createSourceFile('formula.ts', trimmed, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
Comment on lines +396 to +399
if (result === undefined) {
// JSON.stringify(undefined) is undefined, which would leave the
// required `value` field unset and fail downstream validation.
throw new Error(`Formula '${context.name}' did not return a value. Formulas must be an expression or use 'return' to produce a value.`);
@github-actions github-actions Bot added the maintainer-engaged A maintainer has commented or reviewed this item label May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer-engaged A maintainer has commented or reviewed this item

Projects

None yet

Development

Successfully merging this pull request may close these issues.

draft() emits ciceromark Formula node without required "value" field for inline {{% expr %}} formulas

5 participants