ralph-review 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +123 -16
  2. package/package.json +6 -4
  3. package/src/cli-core.ts +51 -88
  4. package/src/cli-rrr.ts +1 -4
  5. package/src/cli.ts +1 -2
  6. package/src/commands/apply.ts +6 -14
  7. package/src/commands/config-handlers.ts +68 -69
  8. package/src/commands/config-model.ts +147 -125
  9. package/src/commands/doctor.ts +2 -4
  10. package/src/commands/fix.ts +73 -51
  11. package/src/commands/handoff-selection.ts +6 -8
  12. package/src/commands/interactive-deps.ts +18 -0
  13. package/src/commands/log.ts +12 -12
  14. package/src/commands/run.ts +32 -33
  15. package/src/commands/stop.ts +6 -13
  16. package/src/commands/update.ts +2 -4
  17. package/src/lib/agents/claude.ts +4 -16
  18. package/src/lib/agents/core.ts +16 -0
  19. package/src/lib/agents/droid.ts +4 -15
  20. package/src/lib/cli-parser.ts +19 -14
  21. package/src/lib/handoff.ts +16 -7
  22. package/src/lib/logging/session-log.ts +2 -1
  23. package/src/lib/prompts/defaults/review.md +1 -1
  24. package/src/lib/prompts/protocol.ts +2 -1
  25. package/src/lib/review-workflow/findings/artifact.ts +3 -1
  26. package/src/lib/review-workflow/findings/types.ts +1 -1
  27. package/src/lib/review-workflow/remediation/prompt.ts +7 -7
  28. package/src/lib/review-workflow/remediation/run-batch-fix-phase.ts +30 -20
  29. package/src/lib/review-workflow/remediation/run-fix-session.ts +70 -68
  30. package/src/lib/review-workflow/results/finalize-result.ts +20 -3
  31. package/src/lib/review-workflow/run-review-cycle.ts +1 -12
  32. package/src/lib/review-workflow/session-status.ts +13 -0
  33. package/src/lib/review-workflow/shared/framed-json.ts +2 -47
  34. package/src/lib/session/state.ts +50 -38
  35. package/src/lib/structured-output.ts +24 -9
  36. package/src/lib/tui/dashboard/HelpOverlay.tsx +13 -57
  37. package/src/lib/tui/dashboard/StatusBar.tsx +12 -50
  38. package/src/lib/tui/dashboard/StopSessionPickerOverlay.tsx +4 -22
  39. package/src/lib/tui/sessions/detail/DetailPane.tsx +6 -64
  40. package/src/lib/tui/sessions/detail/IdleStateView.tsx +10 -12
  41. package/src/lib/tui/sessions/detail/SessionDetailView.tsx +1 -1
  42. package/src/lib/tui/sessions/detail/session-detail-parts.tsx +66 -87
  43. package/src/lib/tui/sessions/history/SessionListOverlay.tsx +17 -75
  44. package/src/lib/tui/sessions/review-summary-parser.ts +2 -68
  45. package/src/lib/tui/shared/CenteredModal.tsx +44 -0
  46. package/src/lib/tui/shared/KeyboardShortcutsModal.tsx +14 -0
  47. package/src/lib/tui/shared/ShortcutHint.tsx +33 -0
  48. package/src/lib/tui/workspace/Workspace.tsx +6 -91
  49. package/src/lib/tui/workspace/use-workspace-state.ts +44 -37
  50. package/src/lib/types/fix.ts +15 -48
  51. package/src/lib/types/guards.ts +47 -0
  52. package/src/lib/types/review.ts +5 -39
@@ -1,72 +1,6 @@
1
+ import { extractBalancedJsonObjectSlices } from "@/lib/structured-output";
1
2
  import { isReviewSummary, type ReviewSummary } from "@/lib/types";
2
3
 
3
- interface JsonObjectSlice {
4
- start: number;
5
- end: number;
6
- value: string;
7
- }
8
-
9
- function extractBalancedJsonObjects(text: string): JsonObjectSlice[] {
10
- const results: JsonObjectSlice[] = [];
11
- let depth = 0;
12
- let startIndex = -1;
13
- let inString = false;
14
- let isEscaped = false;
15
-
16
- for (let index = 0; index < text.length; index += 1) {
17
- const char = text[index];
18
-
19
- if (inString) {
20
- if (isEscaped) {
21
- isEscaped = false;
22
- continue;
23
- }
24
-
25
- if (char === "\\") {
26
- isEscaped = true;
27
- continue;
28
- }
29
-
30
- if (char === '"') {
31
- inString = false;
32
- }
33
- continue;
34
- }
35
-
36
- if (char === '"') {
37
- inString = true;
38
- continue;
39
- }
40
-
41
- if (char === "{") {
42
- if (depth === 0) {
43
- startIndex = index;
44
- }
45
- depth += 1;
46
- continue;
47
- }
48
-
49
- if (char === "}") {
50
- if (depth === 0) {
51
- continue;
52
- }
53
-
54
- depth -= 1;
55
- if (depth === 0 && startIndex >= 0) {
56
- const endIndex = index + 1;
57
- results.push({
58
- start: startIndex,
59
- end: endIndex,
60
- value: text.slice(startIndex, endIndex),
61
- });
62
- startIndex = -1;
63
- }
64
- }
65
- }
66
-
67
- return results;
68
- }
69
-
70
4
  export function extractLatestReviewSummary(
71
5
  text: string,
72
6
  minIndex: number = 0
@@ -75,7 +9,7 @@ export function extractLatestReviewSummary(
75
9
  return null;
76
10
  }
77
11
 
78
- const objects = extractBalancedJsonObjects(text);
12
+ const objects = extractBalancedJsonObjectSlices(text);
79
13
  for (let index = objects.length - 1; index >= 0; index -= 1) {
80
14
  const candidate = objects[index];
81
15
  if (!candidate) {
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ interface CenteredModalProps {
4
+ title: string;
5
+ width: number;
6
+ height?: number;
7
+ padding?: number;
8
+ backgroundColor?: string;
9
+ children: ReactNode;
10
+ }
11
+
12
+ export function CenteredModal({
13
+ title,
14
+ width,
15
+ height,
16
+ padding = 2,
17
+ backgroundColor = "#1a1a2e",
18
+ children,
19
+ }: CenteredModalProps) {
20
+ return (
21
+ <box
22
+ position="absolute"
23
+ left={0}
24
+ top={0}
25
+ width="100%"
26
+ height="100%"
27
+ justifyContent="center"
28
+ alignItems="center"
29
+ >
30
+ <box
31
+ border
32
+ borderStyle="double"
33
+ title={title}
34
+ titleAlignment="left"
35
+ padding={padding}
36
+ width={width}
37
+ height={height}
38
+ backgroundColor={backgroundColor}
39
+ >
40
+ {children}
41
+ </box>
42
+ </box>
43
+ );
44
+ }
@@ -0,0 +1,14 @@
1
+ import { CenteredModal } from "@/lib/tui/shared/CenteredModal";
2
+ import { ShortcutHintList } from "@/lib/tui/shared/ShortcutHint";
3
+
4
+ interface KeyboardShortcutsModalProps {
5
+ shortcuts: readonly { keys: string; label: string }[];
6
+ }
7
+
8
+ export function KeyboardShortcutsModal({ shortcuts }: KeyboardShortcutsModalProps) {
9
+ return (
10
+ <CenteredModal title="Keyboard Shortcuts" width={44}>
11
+ <ShortcutHintList shortcuts={shortcuts} />
12
+ </CenteredModal>
13
+ );
14
+ }
@@ -0,0 +1,33 @@
1
+ import { TUI_COLORS } from "@/lib/tui/shared/colors";
2
+
3
+ interface ShortcutHintProps {
4
+ keys: string;
5
+ label: string;
6
+ }
7
+
8
+ interface ShortcutHintListProps {
9
+ shortcuts: readonly ShortcutHintProps[];
10
+ }
11
+
12
+ export function ShortcutHint({ keys, label }: ShortcutHintProps) {
13
+ return (
14
+ <text>
15
+ <span fg={TUI_COLORS.accent.key}>{keys}</span>
16
+ <span fg={TUI_COLORS.text.muted}> {label}</span>
17
+ </text>
18
+ );
19
+ }
20
+
21
+ export function ShortcutHintList({ shortcuts }: ShortcutHintListProps) {
22
+ return (
23
+ <box flexDirection="column" gap={1}>
24
+ {shortcuts.map((shortcut, index) => (
25
+ <ShortcutHint
26
+ key={`${shortcut.keys}-${index}`}
27
+ keys={shortcut.keys}
28
+ label={shortcut.label}
29
+ />
30
+ ))}
31
+ </box>
32
+ );
33
+ }
@@ -1,51 +1,13 @@
1
- import type {
2
- FindingFixResult,
3
- FindingId,
4
- StoredFinding,
5
- } from "@/lib/review-workflow/findings/types";
6
- import type { SessionState } from "@/lib/session-state";
7
- import type { DashboardStartupMode } from "@/lib/tui/dashboard/use-dashboard-run-control";
1
+ import type { DetailPaneProps } from "@/lib/tui/sessions/detail/DetailPane";
8
2
  import { DetailPane } from "@/lib/tui/sessions/detail/DetailPane";
9
3
  import { SessionSidebar } from "@/lib/tui/sessions/sidebar/SessionSidebar";
10
4
  import { OutputDrawer } from "@/lib/tui/shared/OutputDrawer";
11
- import type {
12
- AgentRole,
13
- Finding,
14
- FixEntry,
15
- ProjectStats,
16
- ReviewOptions,
17
- SessionStats,
18
- SkippedEntry,
19
- } from "@/lib/types";
20
5
  import { resolveWorkspaceFocusState } from "./workspace-focus";
21
6
  import type { FocusedPane, SessionGroupData } from "./workspace-types";
22
7
 
23
- interface WorkspaceProps {
8
+ interface WorkspaceProps extends DetailPaneProps {
24
9
  sessionGroups: SessionGroupData[];
25
10
  selectedSessionId: string | null;
26
- session: SessionState | null;
27
- fixes: FixEntry[];
28
- skipped: SkippedEntry[];
29
- findings: Finding[];
30
- storedFindings: StoredFinding[];
31
- selectedFindingIds: FindingId[];
32
- fixResults: FindingFixResult[];
33
- unresolvedSelectedFindings: StoredFinding[];
34
- auditRegressionFindings: StoredFinding[];
35
- latestReviewIteration: number | null;
36
- codexReviewText: string | null;
37
- tmuxOutput: string;
38
- maxIterations: number;
39
- isLoading: boolean;
40
- lastSessionStats: SessionStats | null;
41
- projectStats: ProjectStats | null;
42
- isGitRepo: boolean;
43
- currentAgent: AgentRole | null;
44
- reviewOptions: ReviewOptions | undefined;
45
- startupMode: DashboardStartupMode;
46
- isStopping: boolean;
47
- activeSessionCount: number;
48
- canFixPendingSession: boolean;
49
11
  outputVisible: boolean;
50
12
  focusedPane: FocusedPane;
51
13
  overlayBlocked?: boolean;
@@ -54,32 +16,10 @@ interface WorkspaceProps {
54
16
  export function Workspace({
55
17
  sessionGroups,
56
18
  selectedSessionId,
57
- session,
58
- fixes,
59
- skipped,
60
- findings,
61
- storedFindings,
62
- selectedFindingIds,
63
- fixResults,
64
- unresolvedSelectedFindings,
65
- auditRegressionFindings,
66
- latestReviewIteration,
67
- codexReviewText,
68
- tmuxOutput,
69
- maxIterations,
70
- isLoading,
71
- lastSessionStats,
72
- projectStats,
73
- isGitRepo,
74
- currentAgent,
75
- reviewOptions,
76
- startupMode,
77
- isStopping,
78
- activeSessionCount,
79
- canFixPendingSession,
80
19
  outputVisible,
81
20
  focusedPane,
82
21
  overlayBlocked = false,
22
+ ...detailPaneProps
83
23
  }: WorkspaceProps) {
84
24
  const { sidebarFocused, detailFocused, outputFocused } = resolveWorkspaceFocusState(
85
25
  focusedPane,
@@ -94,36 +34,11 @@ export function Workspace({
94
34
  selectedSessionId={selectedSessionId}
95
35
  focused={sidebarFocused}
96
36
  />
97
- <DetailPane
98
- session={session}
99
- fixes={fixes}
100
- skipped={skipped}
101
- findings={findings}
102
- storedFindings={storedFindings}
103
- selectedFindingIds={selectedFindingIds}
104
- fixResults={fixResults}
105
- unresolvedSelectedFindings={unresolvedSelectedFindings}
106
- auditRegressionFindings={auditRegressionFindings}
107
- latestReviewIteration={latestReviewIteration}
108
- codexReviewText={codexReviewText}
109
- tmuxOutput={tmuxOutput}
110
- maxIterations={maxIterations}
111
- isLoading={isLoading}
112
- lastSessionStats={lastSessionStats}
113
- projectStats={projectStats}
114
- isGitRepo={isGitRepo}
115
- currentAgent={currentAgent}
116
- reviewOptions={reviewOptions}
117
- startupMode={startupMode}
118
- isStopping={isStopping}
119
- activeSessionCount={activeSessionCount}
120
- canFixPendingSession={canFixPendingSession}
121
- focused={detailFocused}
122
- />
37
+ <DetailPane {...detailPaneProps} focused={detailFocused} />
123
38
  </box>
124
39
  <OutputDrawer
125
- output={tmuxOutput}
126
- sessionName={session?.sessionName ?? null}
40
+ output={detailPaneProps.tmuxOutput}
41
+ sessionName={detailPaneProps.session?.sessionName ?? null}
127
42
  visible={outputVisible}
128
43
  focused={outputFocused}
129
44
  />
@@ -37,6 +37,49 @@ import type { SessionGroupData, WorkspaceState } from "./workspace-types";
37
37
  const DEFAULT_REFRESH_INTERVAL = 1000;
38
38
  const LIVE_REFRESH_INTERVAL = TMUX_CAPTURE_MIN_INTERVAL_MS;
39
39
 
40
+ export function createInitialWorkspaceState(
41
+ overrides: Partial<WorkspaceState> = {}
42
+ ): WorkspaceState {
43
+ return {
44
+ sessionGroups: [],
45
+ allSessions: [],
46
+ projectSessions: [],
47
+ selectedSessionId: null,
48
+ currentSession: null,
49
+ logEntries: [],
50
+ fixes: [],
51
+ skipped: [],
52
+ findings: [],
53
+ storedFindings: [],
54
+ selectedFindingIds: [],
55
+ selectedFindings: [],
56
+ unselectedFindings: [],
57
+ fixResults: [],
58
+ unresolvedSelectedFindings: [],
59
+ auditRegressionFindings: [],
60
+ iterationFixes: [],
61
+ iterationSkipped: [],
62
+ iterationFindings: [],
63
+ latestReviewIteration: null,
64
+ codexReviewText: null,
65
+ tmuxOutput: "",
66
+ elapsed: 0,
67
+ maxIterations: 0,
68
+ error: null,
69
+ liveRefreshError: null,
70
+ isLoading: true,
71
+ lastSessionStats: null,
72
+ projectStats: null,
73
+ config: null,
74
+ configWarning: null,
75
+ isGitRepo: true,
76
+ currentAgent: null,
77
+ reviewOptions: undefined,
78
+ outputVisible: false,
79
+ ...overrides,
80
+ };
81
+ }
82
+
40
83
  function buildSessionGroups(
41
84
  allSessions: ActiveSession[],
42
85
  currentProjectPath: string
@@ -80,43 +123,7 @@ export function useWorkspaceState(
80
123
  _branch?: string,
81
124
  refreshInterval: number = DEFAULT_REFRESH_INTERVAL
82
125
  ): WorkspaceState {
83
- const [state, setState] = useState<WorkspaceState>({
84
- sessionGroups: [],
85
- allSessions: [],
86
- projectSessions: [],
87
- selectedSessionId: null,
88
- currentSession: null,
89
- logEntries: [],
90
- fixes: [],
91
- skipped: [],
92
- findings: [],
93
- storedFindings: [],
94
- selectedFindingIds: [],
95
- selectedFindings: [],
96
- unselectedFindings: [],
97
- fixResults: [],
98
- unresolvedSelectedFindings: [],
99
- auditRegressionFindings: [],
100
- iterationFixes: [],
101
- iterationSkipped: [],
102
- iterationFindings: [],
103
- latestReviewIteration: null,
104
- codexReviewText: null,
105
- tmuxOutput: "",
106
- elapsed: 0,
107
- maxIterations: 0,
108
- error: null,
109
- liveRefreshError: null,
110
- isLoading: true,
111
- lastSessionStats: null,
112
- projectStats: null,
113
- config: null,
114
- configWarning: null,
115
- isGitRepo: true,
116
- currentAgent: null,
117
- reviewOptions: undefined,
118
- outputVisible: false,
119
- });
126
+ const [state, setState] = useState<WorkspaceState>(() => createInitialWorkspaceState());
120
127
 
121
128
  const stateRef = useRef(state);
122
129
  stateRef.current = state;
@@ -1,5 +1,6 @@
1
1
  import type { FixDecision, Priority } from "./domain";
2
2
  import { VALID_FIX_DECISIONS, VALID_PRIORITIES } from "./domain";
3
+ import { asRecord, isCodeLocation } from "./guards";
3
4
  import type { CodeLocation } from "./review";
4
5
 
5
6
  export interface FixEntry {
@@ -26,53 +27,27 @@ export interface FixSummary {
26
27
  skipped: SkippedEntry[];
27
28
  }
28
29
 
29
- function isLineRange(value: unknown): value is CodeLocation["line_range"] {
30
- if (typeof value !== "object" || value === null) {
31
- return false;
32
- }
33
-
34
- const obj = value as Record<string, unknown>;
35
-
30
+ function hasFixEntryHeader(obj: Record<string, unknown>): boolean {
36
31
  return (
37
- typeof obj.start === "number" &&
38
- Number.isInteger(obj.start) &&
39
- obj.start > 0 &&
40
- typeof obj.end === "number" &&
41
- Number.isInteger(obj.end) &&
42
- obj.end >= obj.start
32
+ typeof obj.id === "number" &&
33
+ typeof obj.title === "string" &&
34
+ typeof obj.priority === "string" &&
35
+ VALID_PRIORITIES.includes(obj.priority as Priority)
43
36
  );
44
37
  }
45
38
 
46
- function isCodeLocation(value: unknown): value is CodeLocation {
47
- if (typeof value !== "object" || value === null) {
48
- return false;
49
- }
50
-
51
- const obj = value as Record<string, unknown>;
52
-
53
- if (typeof obj.absolute_file_path !== "string") {
54
- return false;
55
- }
56
-
57
- return isLineRange(obj.line_range);
58
- }
59
-
60
39
  function isFixEntry(value: unknown): value is FixEntry {
61
- if (typeof value !== "object" || value === null) {
40
+ const obj = asRecord(value);
41
+ if (obj === null) {
62
42
  return false;
63
43
  }
64
44
 
65
- const obj = value as Record<string, unknown>;
66
-
67
45
  return (
68
- typeof obj.id === "number" &&
69
- typeof obj.title === "string" &&
70
- typeof obj.priority === "string" &&
71
- VALID_PRIORITIES.includes(obj.priority as Priority) &&
46
+ hasFixEntryHeader(obj) &&
72
47
  (obj.file === undefined || obj.file === null || typeof obj.file === "string") &&
73
48
  (obj.code_location === undefined ||
74
49
  obj.code_location === null ||
75
- isCodeLocation(obj.code_location)) &&
50
+ isCodeLocation(obj.code_location, { requirePositive: true })) &&
76
51
  typeof obj.claim === "string" &&
77
52
  typeof obj.evidence === "string" &&
78
53
  typeof obj.fix === "string"
@@ -80,28 +55,20 @@ function isFixEntry(value: unknown): value is FixEntry {
80
55
  }
81
56
 
82
57
  function isSkippedEntry(value: unknown): value is SkippedEntry {
83
- if (typeof value !== "object" || value === null) {
58
+ const obj = asRecord(value);
59
+ if (obj === null) {
84
60
  return false;
85
61
  }
86
62
 
87
- const obj = value as Record<string, unknown>;
88
-
89
- return (
90
- typeof obj.id === "number" &&
91
- typeof obj.title === "string" &&
92
- typeof obj.priority === "string" &&
93
- VALID_PRIORITIES.includes(obj.priority as Priority) &&
94
- typeof obj.reason === "string"
95
- );
63
+ return hasFixEntryHeader(obj) && typeof obj.reason === "string";
96
64
  }
97
65
 
98
66
  export function isFixSummary(value: unknown): value is FixSummary {
99
- if (typeof value !== "object" || value === null) {
67
+ const obj = asRecord(value);
68
+ if (obj === null) {
100
69
  return false;
101
70
  }
102
71
 
103
- const obj = value as Record<string, unknown>;
104
-
105
72
  // Check decision field
106
73
  if (
107
74
  typeof obj.decision !== "string" ||
@@ -0,0 +1,47 @@
1
+ export function asRecord(value: unknown): Record<string, unknown> | null {
2
+ if (typeof value !== "object" || value === null) {
3
+ return null;
4
+ }
5
+
6
+ return value as Record<string, unknown>;
7
+ }
8
+
9
+ function isInteger(value: unknown): value is number {
10
+ return typeof value === "number" && Number.isInteger(value);
11
+ }
12
+
13
+ function asRecordWithStringField(value: unknown, field: string): Record<string, unknown> | null {
14
+ const obj = asRecord(value);
15
+ if (obj === null || typeof obj[field] !== "string") {
16
+ return null;
17
+ }
18
+
19
+ return obj;
20
+ }
21
+
22
+ function isLineRange(
23
+ value: unknown,
24
+ options: { requirePositive?: boolean } = {}
25
+ ): value is { start: number; end: number } {
26
+ const obj = asRecord(value);
27
+ if (obj === null || !isInteger(obj.start) || !isInteger(obj.end) || obj.end < obj.start) {
28
+ return false;
29
+ }
30
+
31
+ if (options.requirePositive && (obj.start <= 0 || obj.end <= 0)) {
32
+ return false;
33
+ }
34
+
35
+ return true;
36
+ }
37
+
38
+ export function isCodeLocation(
39
+ value: unknown,
40
+ options: { requirePositive?: boolean } = {}
41
+ ): value is {
42
+ absolute_file_path: string;
43
+ line_range: { start: number; end: number };
44
+ } {
45
+ const obj = asRecordWithStringField(value, "absolute_file_path");
46
+ return obj !== null && isLineRange(obj.line_range, options);
47
+ }
@@ -1,5 +1,6 @@
1
1
  import type { OverallCorrectness } from "./domain";
2
2
  import { VALID_OVERALL_CORRECTNESS } from "./domain";
3
+ import { asRecord, isCodeLocation } from "./guards";
3
4
 
4
5
  export interface LineRange {
5
6
  start: number;
@@ -32,46 +33,12 @@ export interface CodexReviewSummary {
32
33
 
33
34
  const DEFAULT_CODEX_CONFIDENCE = 0.69;
34
35
 
35
- function isLineRange(value: unknown): value is LineRange {
36
- if (typeof value !== "object" || value === null) {
37
- return false;
38
- }
39
-
40
- const obj = value as Record<string, unknown>;
41
-
42
- return (
43
- typeof obj.start === "number" &&
44
- Number.isInteger(obj.start) &&
45
- typeof obj.end === "number" &&
46
- Number.isInteger(obj.end)
47
- );
48
- }
49
-
50
- function isCodeLocation(value: unknown): value is CodeLocation {
51
- if (typeof value !== "object" || value === null) {
52
- return false;
53
- }
54
-
55
- const obj = value as Record<string, unknown>;
56
-
57
- if (typeof obj.absolute_file_path !== "string") {
58
- return false;
59
- }
60
-
61
- if (!isLineRange(obj.line_range)) {
62
- return false;
63
- }
64
-
65
- return true;
66
- }
67
-
68
36
  function isFinding(value: unknown): value is Finding {
69
- if (typeof value !== "object" || value === null) {
37
+ const obj = asRecord(value);
38
+ if (obj === null) {
70
39
  return false;
71
40
  }
72
41
 
73
- const obj = value as Record<string, unknown>;
74
-
75
42
  if (typeof obj.title !== "string" || typeof obj.body !== "string") {
76
43
  return false;
77
44
  }
@@ -99,12 +66,11 @@ function isFinding(value: unknown): value is Finding {
99
66
  }
100
67
 
101
68
  export function isReviewSummary(value: unknown): value is ReviewSummary {
102
- if (typeof value !== "object" || value === null) {
69
+ const obj = asRecord(value);
70
+ if (obj === null) {
103
71
  return false;
104
72
  }
105
73
 
106
- const obj = value as Record<string, unknown>;
107
-
108
74
  if (!Array.isArray(obj.findings)) {
109
75
  return false;
110
76
  }