Skip to content

Commit bbfaa16

Browse files
committed
fix(diagnostics): defensive guards on new validators (CodeRabbit #473 review)
CodeRabbit flagged two patterns in the per-store validators added in the parent commit: 1. .trim() on .title / .narrative was unconditional — a corrupted row with title=null or title=42 would throw, abort the whole diagnose run, and silently skip every later category. Add typeof guards. 2. confidence range checks were `< 0 || > 1` which silently passes NaN and Infinity (NaN < 0 is false, NaN > 1 is false → "healthy"). Add Number.isFinite(...) prefix so corrupted scored rows surface as warnings instead. Applied across all 6 new validators: lesson confidence, summary title, semantic confidence, crystal narrative, insight confidence. Tests added in test/diagnostics.test.ts under "defensive row-shape handling": NaN confidence on a lesson, null summary title (verifies diagnose still completes and later categories still execute), undefined crystal narrative, Infinity / NaN on insight + semantic. 34/34 tests pass.
1 parent 44a3638 commit bbfaa16

2 files changed

Lines changed: 130 additions & 8 deletions

File tree

src/functions/diagnostics.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,21 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void {
374374
const live = lessons.filter((l) => !l.deleted);
375375
let lessonIssues = 0;
376376
for (const l of live) {
377-
if (l.confidence < 0 || l.confidence > 1) {
377+
// Number.isFinite rejects NaN / Infinity / non-numbers; a
378+
// corrupted row passing those would silently survive the < / >
379+
// range check (e.g. NaN < 0 is false, NaN > 1 is false, so the
380+
// bad row would be "healthy") and skew memory_lesson_recall's
381+
// scoring downstream. Surface as warning.
382+
if (
383+
!Number.isFinite(l.confidence) ||
384+
l.confidence < 0 ||
385+
l.confidence > 1
386+
) {
378387
checks.push({
379388
name: `lesson-bad-confidence:${l.id}`,
380389
category: "lessons",
381390
status: "warn",
382-
message: `Lesson ${l.id} has confidence ${l.confidence} (expected 0..1)`,
391+
message: `Lesson ${l.id} has confidence ${l.confidence} (expected finite number in 0..1)`,
383392
fixable: false,
384393
});
385394
lessonIssues++;
@@ -400,7 +409,10 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void {
400409
const summaries = await kv.list<SessionSummary>(KV.summaries);
401410
let summaryIssues = 0;
402411
for (const s of summaries) {
403-
if (!s.title || !s.title.trim()) {
412+
// typeof guard before .trim() — a corrupted row with title=null
413+
// or title=42 would otherwise throw and abort the whole diagnose
414+
// run before later categories get checked.
415+
if (typeof s.title !== "string" || s.title.trim().length === 0) {
404416
checks.push({
405417
name: `summary-missing-title:${s.sessionId}`,
406418
category: "summaries",
@@ -426,12 +438,16 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void {
426438
const semantic = await kv.list<SemanticMemory>(KV.semantic);
427439
let semanticIssues = 0;
428440
for (const s of semantic) {
429-
if (s.confidence < 0 || s.confidence > 1) {
441+
if (
442+
!Number.isFinite(s.confidence) ||
443+
s.confidence < 0 ||
444+
s.confidence > 1
445+
) {
430446
checks.push({
431447
name: `semantic-bad-confidence:${s.id}`,
432448
category: "semantic",
433449
status: "warn",
434-
message: `Semantic fact ${s.id} has confidence ${s.confidence} (expected 0..1)`,
450+
message: `Semantic fact ${s.id} has confidence ${s.confidence} (expected finite number in 0..1)`,
435451
fixable: false,
436452
});
437453
semanticIssues++;
@@ -478,7 +494,7 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void {
478494
const crystals = await kv.list<Crystal>(KV.crystals);
479495
let crystalIssues = 0;
480496
for (const c of crystals) {
481-
if (!c.narrative || !c.narrative.trim()) {
497+
if (typeof c.narrative !== "string" || c.narrative.trim().length === 0) {
482498
checks.push({
483499
name: `crystal-empty-narrative:${c.id}`,
484500
category: "crystals",
@@ -504,12 +520,16 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void {
504520
const insights = await kv.list<Insight>(KV.insights);
505521
let insightIssues = 0;
506522
for (const i of insights) {
507-
if (i.confidence < 0 || i.confidence > 1) {
523+
if (
524+
!Number.isFinite(i.confidence) ||
525+
i.confidence < 0 ||
526+
i.confidence > 1
527+
) {
508528
checks.push({
509529
name: `insight-bad-confidence:${i.id}`,
510530
category: "insights",
511531
status: "warn",
512-
message: `Insight ${i.id} has confidence ${i.confidence} (expected 0..1)`,
532+
message: `Insight ${i.id} has confidence ${i.confidence} (expected finite number in 0..1)`,
513533
fixable: false,
514534
});
515535
insightIssues++;

test/diagnostics.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,5 +761,107 @@ describe("Diagnostics Functions", () => {
761761
expect(result.checks.some((c) => c.category === "lessons")).toBe(true);
762762
expect(result.checks.some((c) => c.category === "summaries")).toBe(true);
763763
});
764+
765+
describe("defensive row-shape handling (CodeRabbit #473 review)", () => {
766+
it("NaN/Infinity confidence on a lesson is flagged as warn, not silently passed", async () => {
767+
await kv.set(KV.lessons, "lsn_nan", {
768+
id: "lsn_nan", content: "x", context: "", confidence: NaN,
769+
reinforcements: 0, source: "manual", sourceIds: [], tags: [],
770+
createdAt: "", updatedAt: "", decayRate: 0.05,
771+
});
772+
773+
const result = (await sdk.trigger("mem::diagnose", {
774+
categories: ["lessons"],
775+
})) as { checks: DiagnosticCheck[] };
776+
777+
const warn = result.checks.find((c) => c.name.startsWith("lesson-bad-confidence:"));
778+
expect(warn?.status).toBe("warn");
779+
});
780+
781+
it("non-string summary title doesn't throw — surfaces as warn", async () => {
782+
await kv.set(KV.summaries, "ses_bad_title", {
783+
sessionId: "ses_bad_title",
784+
project: "p",
785+
createdAt: "",
786+
title: null as unknown as string, // simulate corrupted row
787+
narrative: "n",
788+
keyDecisions: [],
789+
filesModified: [],
790+
concepts: [],
791+
observationCount: 1,
792+
});
793+
794+
// The bug to guard against: the old code called .trim() unconditionally,
795+
// which throws on null/number, which aborts the whole diagnose run and
796+
// any later category check never executes. Verify diagnose completes
797+
// AND surfaces the bad row.
798+
const result = (await sdk.trigger("mem::diagnose", {
799+
categories: ["summaries", "lessons"],
800+
})) as { checks: DiagnosticCheck[]; success?: boolean };
801+
802+
expect(result.success).toBe(true);
803+
const warn = result.checks.find((c) => c.name.startsWith("summary-missing-title:"));
804+
expect(warn?.status).toBe("warn");
805+
// Later category still ran:
806+
expect(result.checks.some((c) => c.category === "lessons")).toBe(true);
807+
});
808+
809+
it("non-string crystal narrative doesn't throw — surfaces as warn", async () => {
810+
await kv.set(KV.crystals, "cry_bad", {
811+
id: "cry_bad",
812+
narrative: undefined as unknown as string,
813+
keyOutcomes: [],
814+
filesAffected: [],
815+
lessons: [],
816+
sourceActionIds: [],
817+
createdAt: "",
818+
});
819+
820+
const result = (await sdk.trigger("mem::diagnose", {
821+
categories: ["crystals"],
822+
})) as { checks: DiagnosticCheck[]; success?: boolean };
823+
824+
expect(result.success).toBe(true);
825+
const warn = result.checks.find((c) => c.name.startsWith("crystal-empty-narrative:"));
826+
expect(warn?.status).toBe("warn");
827+
});
828+
829+
it("Infinity confidence on insight + semantic both flagged", async () => {
830+
await kv.set(KV.insights, "ins_inf", {
831+
id: "ins_inf",
832+
title: "t",
833+
content: "c",
834+
confidence: Infinity,
835+
reinforcements: 0,
836+
sourceConceptCluster: [],
837+
sourceMemoryIds: [],
838+
sourceLessonIds: [],
839+
sourceCrystalIds: [],
840+
tags: [],
841+
createdAt: "",
842+
updatedAt: "",
843+
decayRate: 0.05,
844+
});
845+
await kv.set(KV.semantic, "sem_nan", {
846+
id: "sem_nan",
847+
fact: "f",
848+
confidence: NaN,
849+
sourceSessionIds: [],
850+
sourceMemoryIds: [],
851+
accessCount: 0,
852+
lastAccessedAt: "",
853+
strength: 0,
854+
createdAt: "",
855+
updatedAt: "",
856+
});
857+
858+
const result = (await sdk.trigger("mem::diagnose", {
859+
categories: ["insights", "semantic"],
860+
})) as { checks: DiagnosticCheck[] };
861+
862+
expect(result.checks.find((c) => c.name === "insight-bad-confidence:ins_inf")?.status).toBe("warn");
863+
expect(result.checks.find((c) => c.name === "semantic-bad-confidence:sem_nan")?.status).toBe("warn");
864+
});
865+
});
764866
});
765867
});

0 commit comments

Comments
 (0)