From be6eaee6d0086b2099882d2a56108ab261c81fea Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:18:10 +0200 Subject: [PATCH] fix(01-02): TS-strict narrowing for EvalResult discriminated union Rule 1 deviation: TS strict mode (noImplicitAny + strict) couldn't narrow the EvalResult union via 'r.ok' direct access because the {unknown:true,raw} variant has no 'ok' property. Added explicit type-guard helpers (isUnknown, isOk, isFail) for both production and spec narrowing. Runtime behavior unchanged; tsc --noEmit now exits clean for the leveling lib. Files: - prereq-evaluator.ts: 3 type-guard functions, used in OR/AND walkers - prereq-evaluator.spec.ts: isFail() in lieu of result.ok=== checks --- .../leveling/lib/prereq-evaluator.spec.ts | 18 +++++++++------ .../modules/leveling/lib/prereq-evaluator.ts | 22 ++++++++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/server/src/modules/leveling/lib/prereq-evaluator.spec.ts b/server/src/modules/leveling/lib/prereq-evaluator.spec.ts index bab4d3e..9dfe1a0 100644 --- a/server/src/modules/leveling/lib/prereq-evaluator.spec.ts +++ b/server/src/modules/leveling/lib/prereq-evaluator.spec.ts @@ -1,5 +1,9 @@ import { evaluatePrereq } from './prereq-evaluator'; -import type { CharacterContext } from './types'; +import type { CharacterContext, EvalResult } from './types'; + +function isFail(r: EvalResult): r is { ok: false; reason: string } { + return 'ok' in r && r.ok === false; +} function makeCtx(overrides: Partial = {}): CharacterContext { return { @@ -38,8 +42,8 @@ describe('evaluatePrereq — skill rank', () => { it('returns ok:false with German reason when skill rank below requirement', () => { const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } }); const result = evaluatePrereq('Trained in Athletics', ctx); - expect(result.ok).toBe(false); - if (result.ok === false) { + expect(isFail(result)).toBe(true); + if (isFail(result)) { expect(result.reason).toMatch(/Athletics/i); // German wording check — must contain a German keyword expect(result.reason).toMatch(/(benötig|fehlt|Voraussetzung|mindestens)/); @@ -49,7 +53,7 @@ describe('evaluatePrereq — skill rank', () => { it('returns ok:false when skill is missing entirely', () => { const ctx = makeCtx({ skills: {} }); const result = evaluatePrereq('Trained in Athletics', ctx); - expect(result.ok).toBe(false); + expect(isFail(result)).toBe(true); }); }); @@ -67,7 +71,7 @@ describe('evaluatePrereq — disjunctive (OR-list)', () => { 'Trained in Arcana, Trained in Nature, or Trained in Religion', ctx, ); - expect(result.ok).toBe(false); + expect(isFail(result)).toBe(true); }); }); @@ -82,7 +86,7 @@ describe('evaluatePrereq — conjunctive (semicolon AND)', () => { it('returns ok:false when one clause is missing', () => { const ctx = makeCtx({ skills: { Deception: 'TRAINED' } }); const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx); - expect(result.ok).toBe(false); + expect(isFail(result)).toBe(true); }); }); @@ -95,7 +99,7 @@ describe('evaluatePrereq — bare feat name', () => { it('returns ok:false when the feat is missing', () => { const ctx = makeCtx({ feats: new Set() }); const result = evaluatePrereq('Power Attack', ctx); - expect(result.ok).toBe(false); + expect(isFail(result)).toBe(true); }); }); diff --git a/server/src/modules/leveling/lib/prereq-evaluator.ts b/server/src/modules/leveling/lib/prereq-evaluator.ts index 9fb21ad..20e2c70 100644 --- a/server/src/modules/leveling/lib/prereq-evaluator.ts +++ b/server/src/modules/leveling/lib/prereq-evaluator.ts @@ -291,6 +291,18 @@ type NodeResult = | { ok: false; reason: string } | { unknown: true; raw: string }; +function isUnknown(r: NodeResult): r is { unknown: true; raw: string } { + return 'unknown' in r; +} + +function isOk(r: NodeResult): r is { ok: true } { + return 'ok' in r && r.ok === true; +} + +function isFail(r: NodeResult): r is { ok: false; reason: string } { + return 'ok' in r && r.ok === false; +} + function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResult { if (node.kind === 'atom') { return evaluateAtom(node.atom, ctx); @@ -300,12 +312,12 @@ function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResul let firstFailReason: string | null = null; for (const child of node.children) { const r = evaluateNode(child, ctx, raw); - if ('unknown' in r && r.unknown) { + if (isUnknown(r)) { // UNKNOWN-aggressive: any unknown atom poisons the whole prereq. return { unknown: true, raw }; } - if (r.ok) return { ok: true }; - if (firstFailReason === null && !r.ok) { + if (isOk(r)) return { ok: true }; + if (firstFailReason === null && isFail(r)) { firstFailReason = r.reason; } } @@ -318,10 +330,10 @@ function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResul // AND for (const child of node.children) { const r = evaluateNode(child, ctx, raw); - if ('unknown' in r && r.unknown) { + if (isUnknown(r)) { return { unknown: true, raw }; } - if (!r.ok) return r; + if (isFail(r)) return r; } return { ok: true }; }