Skip to content

fix VerseRef.chapterNum setter infinite recursion - #58

Merged
irahopkinson merged 1 commit into
mainfrom
fix-chapternum-setter
Aug 4, 2026
Merged

fix VerseRef.chapterNum setter infinite recursion#58
irahopkinson merged 1 commit into
mainfrom
fix-chapternum-setter

Conversation

@irahopkinson

Copy link
Copy Markdown
Collaborator

Fixes the VerseRef.chapterNum setter, which recursed infinitely on every assignment. Built test-first: the ported C# test was confirmed failing with RangeError before the fix went in.

The bug

src/verse-ref.ts assigned the property to itself instead of the backing field, behind a ToDo placeholder:

set chapterNum(value: number) {
  // ToDo: replace or remove this placeholder
  this.chapterNum = value;
}

There's no guard in front of it, so every input threw RangeError: Maximum call stack size exceeded= 5, = 0, = -1 alike. The property was completely unusable.

Nothing inside the library was affected: the numeric constructor writes _chapterNum directly (verse-ref.ts:244), and setEmpty() and the chapter string setter do the same. That line was the only write through the setter, so this was reachable only by external consumers.

The fix

Ported from the C# VerseRef.ChapterNum, which guards negatives then assigns:

public int ChapterNum
{
    get { return chapterNum; }
    set
    {
        if (value < 0)
            throw new VerseRefException("ChapterNum can not be negative");
        chapterNum = (short)value;
    }
}

giving:

set chapterNum(value: number) {
  if (value < 0) {
    throw new VerseRefException('ChapterNum can not be negative');
  }
  this._chapterNum = value;
}

This mirrors the existing bookNum setter, which already ports its C# counterpart's throw verbatim. The (short) cast is deliberately not replicated — TS has no short, and emulating 16-bit wraparound would be fidelity at the cost of sense.

The -1 sentinel path is untouched. setEmpty() and set chapter('') write _chapterNum directly, so the new guard never intercepts them — exactly as in the C#, where Chapter sets the field to -1 without going through ChapterNum. Chapter and Verse as Empty Strings still passes.

Tests

Ports BuildVerseRefByProps from SIL.Scripture.Tests, which builds a VerseRef entirely through property setters and so covers this path directly.

One assertion is commented out. After bookNum = 13, the C# expects OutOfRange because chapter 0 is invalid, but internalValid() has its chapter/verse range check commented out pending the versification port (see the TODO at verse-ref.ts:623), so it returns Valid. Commented rather than weakened, following the existing convention in this file, and it should be restored when that port lands. The other five validStatus/valid assertions in the ported test pass legitimately.

Also adds two TS-only tests under Extra (TS-only tests) for the negative guard and the zero boundary, since the C# has no direct test for the setter's validation.

Is this a behaviour change?

Technically yes, but not a breaking one. Since every prior assignment crashed, there is no code path that worked before and behaves differently now — this is a patch-level repair of a property that was 100% unusable. The public API surface is unchanged; the .d.ts declarations for chapterNum are identical.

The only way to have depended on the old behaviour was catching the stack overflow as control flow, and even that largely survives: negatives still throw, now as VerseRefException rather than RangeError.

Verification

  • npm run test:ci — 43 passed (was 40)
  • npm run lint — clean
  • npm run prettier:ci — clean
  • npm run build — clean

Red was confirmed twice before the fix: first RangeError from the recursion for the ported test, then expected function to throw an error, but it didn't for the negative guard.

Follow-up

set verseNum is the same class of mis-port and still carries its ToDo. The C# is:

if (value < 0) throw new VerseRefException("VerseNum can not be negative");
verseNum = (short)value;
verse = null;

The TS does a bare this._verseNum = value — missing both the guard and the verse = null. So new VerseRef('LUK','3','4b-5a') then verseNum = 9 leaves a stale '4b-5a' in the verse getter. Deliberately left out of this PR: unlike chapterNum, that setter works today, so fixing it changes the observable output of working consumer code and deserves its own review.

🤖 Generated with Claude Code

The setter assigned to itself (`this.chapterNum = value`) instead of the
backing field, so any assignment recursed until `RangeError: Maximum call
stack size exceeded`. It was marked with a `ToDo` placeholder.

Ported from the C# `VerseRef.ChapterNum` setter, which guards against
negative values before assigning:

    if (value < 0)
        throw new VerseRefException("ChapterNum can not be negative");
    chapterNum = (short)value;

Tests port `BuildVerseRefByProps` from SIL.Scripture.Tests, which builds a
VerseRef entirely through property setters and so covers this directly. One
assertion is commented out: chapter 0 is out of range in the C#, but
`internalValid()` cannot detect that until the versification port lands.

Also adds TS-only tests for the negative guard and the zero boundary, since
the C# has no direct test for the setter's validation.

The `-1` sentinel path is unaffected: `setEmpty()` and the `chapter` string
setter write the backing field directly, exactly as in the C#.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.93%. Comparing base (7c885cf) to head (bf456ee).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #58      +/-   ##
==========================================
+ Coverage   81.40%   83.93%   +2.53%     
==========================================
  Files           4        4              
  Lines         328      330       +2     
  Branches       76       77       +1     
==========================================
+ Hits          267      277      +10     
+ Misses         39       33       -6     
+ Partials       22       20       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@irahopkinson
irahopkinson merged commit 766361c into main Aug 4, 2026
3 checks passed
@irahopkinson
irahopkinson deleted the fix-chapternum-setter branch August 4, 2026 00:05
irahopkinson added a commit that referenced this pull request Aug 10, 2026
The setter assigned the backing field and nothing else, marked with a `ToDo`
placeholder. It was missing both of the other things the C# `VerseRef.VerseNum`
setter does:

    if (value < 0)
        throw new VerseRefException("VerseNum can not be negative");
    verseNum = (short)value;
    verse = null;

So negative verse numbers were accepted, and assigning `verseNum` left the
range/segment string in place. Setting `verseNum = 9` on `LUK 3:4b-5a` left a
stale `4b-5a` in the `verse` getter and `hasMultiple` still `true`.

Uses `this._verse = undefined` rather than `null`, per the repo convention of
preferring `undefined` for missing values. The C# `(short)` cast is not
replicated.

The C# test that covers the range-clearing is `CopyVerseFrom`, which sets
`VerseNum = 9` on a `LUK 3:4b-6a` source and then asserts the copied `Verse` is
`"9"`. That test cannot be ported yet — `copyVerseFrom` is not implemented in
this port — so the cases are added as TS-only tests instead: the negative
guard, the zero boundary, and the clearing of both a range and a segment.

`BuildVerseRefByProps` already exercised `verseNum = 0/15/17` and still passes;
those are plain numbers with no verse string to clear.

Behaviour change for consumers: unlike the sibling `chapterNum` setter fixed in
#58, this setter worked before, so code reading `verse`/`hasMultiple` after
assigning `verseNum` will see different results.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irahopkinson added a commit that referenced this pull request Aug 10, 2026
The setter assigned the backing field and nothing else, marked with a `ToDo`
placeholder. It was missing both of the other things the C# `VerseRef.VerseNum`
setter does:

    if (value < 0)
        throw new VerseRefException("VerseNum can not be negative");
    verseNum = (short)value;
    verse = null;

So negative verse numbers were accepted, and assigning `verseNum` left the
range/segment string in place. Setting `verseNum = 9` on `LUK 3:4b-5a` left a
stale `4b-5a` in the `verse` getter and `hasMultiple` still `true`.

Uses `this._verse = undefined` rather than `null`, per the repo convention of
preferring `undefined` for missing values. The C# `(short)` cast is not
replicated.

The C# test that covers the range-clearing is `CopyVerseFrom`, which sets
`VerseNum = 9` on a `LUK 3:4b-6a` source and then asserts the copied `Verse` is
`"9"`. That test cannot be ported yet — `copyVerseFrom` is not implemented in
this port — so the cases are added as TS-only tests instead: the negative
guard, the zero boundary, and the clearing of both a range and a segment.

`BuildVerseRefByProps` already exercised `verseNum = 0/15/17` and still passes;
those are plain numbers with no verse string to clear.

Behaviour change for consumers: unlike the sibling `chapterNum` setter fixed in
#58, this setter worked before, so code reading `verse`/`hasMultiple` after
assigning `verseNum` will see different results.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
irahopkinson added a commit to eten-tech-foundation/scripture-editors that referenced this pull request Aug 25, 2026
Routine minor bump, not a security update: the advisory-ID set is
unchanged (GHSA-g2r8-wvmj-jf5w, GHSA-vp3h-ghgh-jr7g before and after),
so this neither closes nor opens a finding. No overrides needed.

Skips 2.0.6, so it also picks up the `VerseRef.chapterNum` setter
infinite-recursion fix (sillsdev/scripture#58).

2.1.0 is flagged upstream as a potential breaking change: the
`VerseRef.verseNum` setter was a mis-port that assigned the backing
field and nothing else, and now also clears the verse string and throws
on negative values, matching the C# `VerseRef.VerseNum` it ports.

We are not affected. That is an instance setter on the `VerseRef`
class, and outside the vendored `demos/platform/lib` copy this repo
never touches the class — only `SerializedVerseRef`, an interface whose
`verseNum` is a plain data property with no setter. The two
`new VerseRef(...)` sites in platform-bible-utils' scripture-util.ts are
read-only expressions that never store the instance, and every
`verseNum`/`chapterNum` assignment in the tree targets a plain object or
a local number. The upstream release notes survey this repo by name and
reach the same conclusion.

2.1.0 declares no dependencies, so the lockfile diff is confined to the
three specifiers and the single package entry.

Verified with `nx run-many -t build test lint typecheck --skip-nx-cache`
across all 10 projects: green, 1793 tests passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
irahopkinson added a commit to eten-tech-foundation/scripture-editors that referenced this pull request Aug 25, 2026
Routine minor bump, not a security update: the advisory-ID set is
unchanged (GHSA-g2r8-wvmj-jf5w, GHSA-vp3h-ghgh-jr7g before and after),
so this neither closes nor opens a finding. No overrides needed.

Skipping 2.0.6 means this picks up two `VerseRef` setter repairs at
once. Neither changes our behaviour today — we hold no `VerseRef`
instance to assign to — but both were armed traps that would have
fired the first time we reached for that API, so taking the bump
disarms them ahead of the need rather than after it.

`chapterNum` (2.0.6, sillsdev/scripture#58) assigned the property to
itself behind a `ToDo` placeholder and recursed infinitely. There was
no guard in front of it, so every input threw `RangeError: Maximum call
stack size exceeded` — `= 5`, `= 0`, `= -1` alike. The property was
completely unusable.

`verseNum` (2.1.0, sillsdev/scripture#60) is the same class of
mis-port, and is flagged upstream as a potential breaking change
because it did work. It assigned the backing field and nothing else,
where the C# it ports also clears the verse string and rejects
negatives. So `new VerseRef('LUK','3','4b-5a')` then `verseNum = 9`
left `verse` reading '4b-5a' and `hasMultiple` true, describing the old
reference while `verseNum` described the new one.

Of the two, `verseNum` was the more dangerous to us: the recursion
fails loud on first use, while the stale string fails silent and flows
onward into anything reading `verse` or `toString()`.

We reach neither today. Both are instance setters on the `VerseRef`
class, and outside the vendored `demos/platform/lib` copy this repo
never touches the class — only `SerializedVerseRef`, an interface whose
`verseNum` is a plain data property with no setter. The two
`new VerseRef(...)` sites in platform-bible-utils' scripture-util.ts are
read-only expressions that never store the instance, and every
`verseNum`/`chapterNum` assignment in the tree targets a plain object or
a local number. The upstream release notes survey this repo by name and
reach the same conclusion.

2.1.0 declares no dependencies, so the lockfile diff is confined to the
three specifiers and the single package entry.

Verified with `nx run-many -t build test lint typecheck --skip-nx-cache`
across all 10 projects: green, 1793 tests passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
irahopkinson added a commit to eten-tech-foundation/scripture-editors that referenced this pull request Aug 27, 2026
Routine minor bump, not a security update: the advisory-ID set is
unchanged (GHSA-g2r8-wvmj-jf5w, GHSA-vp3h-ghgh-jr7g before and after),
so this neither closes nor opens a finding. No overrides needed.

Skipping 2.0.6 means this picks up two `VerseRef` setter repairs at
once. Neither changes our behaviour today — we hold no `VerseRef`
instance to assign to — but both were armed traps that would have
fired the first time we reached for that API, so taking the bump
disarms them ahead of the need rather than after it.

`chapterNum` (2.0.6, sillsdev/scripture#58) assigned the property to
itself behind a `ToDo` placeholder and recursed infinitely. There was
no guard in front of it, so every input threw `RangeError: Maximum call
stack size exceeded` — `= 5`, `= 0`, `= -1` alike. The property was
completely unusable.

`verseNum` (2.1.0, sillsdev/scripture#60) is the same class of
mis-port, and is flagged upstream as a potential breaking change
because it did work. It assigned the backing field and nothing else,
where the C# it ports also clears the verse string and rejects
negatives. So `new VerseRef('LUK','3','4b-5a')` then `verseNum = 9`
left `verse` reading '4b-5a' and `hasMultiple` true, describing the old
reference while `verseNum` described the new one.

Of the two, `verseNum` was the more dangerous to us: the recursion
fails loud on first use, while the stale string fails silent and flows
onward into anything reading `verse` or `toString()`.

We reach neither today. Both are instance setters on the `VerseRef`
class, and outside the vendored `demos/platform/lib` copy this repo
never touches the class — only `SerializedVerseRef`, an interface whose
`verseNum` is a plain data property with no setter. The two
`new VerseRef(...)` sites in platform-bible-utils' scripture-util.ts are
read-only expressions that never store the instance, and every
`verseNum`/`chapterNum` assignment in the tree targets a plain object or
a local number. The upstream release notes survey this repo by name and
reach the same conclusion.

2.1.0 declares no dependencies, so the lockfile diff is confined to the
three specifiers and the single package entry.

Verified with `nx run-many -t build test lint typecheck --skip-nx-cache`
across all 10 projects: green, 1793 tests passed.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant