diff --git a/frontend/src/app/playground/page.tsx b/frontend/src/app/playground/page.tsx
index 187ba7cf..708532a5 100644
--- a/frontend/src/app/playground/page.tsx
+++ b/frontend/src/app/playground/page.tsx
@@ -1,25 +1,25 @@
'use client';
import { VirtualizedFileTree, type FileTreeNode } from '@/components/explorer/VirtualizedFileTree';
-import dynamic from 'next/dynamic';
-const CodeEditor = dynamic(() => import('@/components/playground/CodeEditor').then((mod) => mod.CodeEditor), {
- ssr: false,
-});
import { OfflineIndicator } from '@/components/storage/OfflineIndicator';
import {
- CompileOutputTerminal,
- type CompileLogEntry,
+ CompileOutputTerminal,
+ type CompileLogEntry,
} from '@/components/terminal/CompileOutputTerminal';
import { TerminalPanel } from '@/components/terminal/TerminalPanel';
import { WithSkeleton } from '@/components/ui/WithSkeleton';
import { EditorSkeleton } from '@/components/ui/skeletons/EditorSkeleton';
import { useTutorial } from '@/contexts/TutorialContext';
-import { useState, useEffect, useMemo, useCallback } from 'react';
import { CollaborationProvider } from '@/lib/collaboration/YjsProvider';
+import { FilePresenceManager } from '@/lib/explorer/FilePresence';
import { DatabaseManager } from '@/lib/storage/DatabaseManager';
import { SyncManager } from '@/lib/storage/SyncManager';
-import { FilePresenceManager } from '@/lib/explorer/FilePresence';
import { Settings, X } from 'lucide-react';
+import dynamic from 'next/dynamic';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+const CodeEditor = dynamic(() => import('@/components/playground/CodeEditor').then((mod) => mod.CodeEditor), {
+ ssr: false,
+});
const INITIAL_TREE: FileTreeNode[] = [
{
@@ -113,6 +113,12 @@ export default function PlaygroundPage() {
const [pendingCount, setPendingCount] = useState(0);
const [activeTab, setActiveTab] = useState<'editor' | 'output'>('editor');
+ // Track the live editor code so the audit panel can analyse it
+ const [editorCode, setEditorCode] = useState('');
+ const { result: auditResult, isPending: auditPending, runAudit } = useAccessibilityAudit(editorCode, {
+ debounceMs: 500,
+ });
+
useEffect(() => {
const timer = setTimeout(() => setIsInitializing(false), 1500);
return () => clearTimeout(timer);
@@ -355,6 +361,7 @@ export default function PlaygroundPage() {
roomName="main-lab-session"
collaborationProvider={provider}
settings={editorSettings}
+ onCodeChange={setEditorCode}
/>
@@ -379,6 +386,12 @@ export default function PlaygroundPage() {
+
+
Laboratory Notes
diff --git a/frontend/src/components/playground/AccessibilityAuditPanel.tsx b/frontend/src/components/playground/AccessibilityAuditPanel.tsx
new file mode 100644
index 00000000..ec474e86
--- /dev/null
+++ b/frontend/src/components/playground/AccessibilityAuditPanel.tsx
@@ -0,0 +1,369 @@
+'use client';
+
+import type { AuditIssue, AuditResult, AuditSeverity } from '@/lib/editor/SorobanAccessibilityAuditor';
+import { AlertCircle, AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Info, RefreshCw } from 'lucide-react';
+import React, { useId, useState } from 'react';
+
+// ---------------------------------------------------------------------------
+// Sub-components
+// ---------------------------------------------------------------------------
+
+interface SeverityBadgeProps {
+ severity: AuditSeverity;
+ count: number;
+}
+
+function SeverityBadge({ severity, count }: SeverityBadgeProps) {
+ const styles: Record = {
+ error: 'bg-red-900/40 text-red-400 border-red-700/40',
+ warning: 'bg-amber-900/40 text-amber-400 border-amber-700/40',
+ info: 'bg-blue-900/40 text-blue-400 border-blue-700/40',
+ };
+
+ const label: Record = {
+ error: 'ERR',
+ warning: 'WARN',
+ info: 'INFO',
+ };
+
+ return (
+
+ {label[severity]} {count}
+
+ );
+}
+
+interface IssueRowProps {
+ issue: AuditIssue;
+ defaultExpanded?: boolean;
+}
+
+function IssueRow({ issue, defaultExpanded = false }: IssueRowProps) {
+ const [expanded, setExpanded] = useState(defaultExpanded);
+ const detailId = useId();
+
+ const iconMap: Record = {
+ error: ,
+ warning: ,
+ info: ,
+ };
+
+ const severityColor: Record = {
+ error: 'text-red-400',
+ warning: 'text-amber-400',
+ info: 'text-blue-400',
+ };
+
+ const borderColor: Record = {
+ error: 'border-red-700/30 hover:border-red-600/50',
+ warning: 'border-amber-700/30 hover:border-amber-600/50',
+ info: 'border-blue-700/30 hover:border-blue-600/50',
+ };
+
+ return (
+
+ {/* Issue header — clickable to expand / collapse */}
+
+
+ {/* Expanded suggestion */}
+ {expanded && (
+
+
+
+ FIX
+
+ {issue.suggestion}
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Filter tabs
+// ---------------------------------------------------------------------------
+
+type FilterTab = AuditSeverity | 'all';
+
+interface FilterTabsProps {
+ active: FilterTab;
+ counts: AuditResult['counts'];
+ total: number;
+ onChange: (tab: FilterTab) => void;
+}
+
+function FilterTabs({ active, counts, total, onChange }: FilterTabsProps) {
+ const tabs: Array<{ id: FilterTab; label: string; count: number }> = [
+ { id: 'all', label: 'All', count: total },
+ { id: 'error', label: 'Errors', count: counts.error },
+ { id: 'warning', label: 'Warnings', count: counts.warning },
+ { id: 'info', label: 'Info', count: counts.info },
+ ];
+
+ return (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main panel
+// ---------------------------------------------------------------------------
+
+export interface AccessibilityAuditPanelProps {
+ /** The current audit result; null while not yet computed */
+ result: AuditResult | null;
+ /** Whether an audit is queued / in-flight */
+ isPending: boolean;
+ /** Callback to manually trigger a fresh audit */
+ onRunAudit?: () => void;
+ /** Extra classes for the root element */
+ className?: string;
+}
+
+export function AccessibilityAuditPanel({
+ result,
+ isPending,
+ onRunAudit,
+ className = '',
+}: AccessibilityAuditPanelProps) {
+ const [activeFilter, setActiveFilter] = useState('all');
+ const headingId = useId();
+
+ // Reset filter when a new result arrives so we don't show an empty list
+ React.useEffect(() => {
+ setActiveFilter('all');
+ }, [result]);
+
+ const filteredIssues: AuditIssue[] = result
+ ? activeFilter === 'all'
+ ? result.issues
+ : result.issues.filter((i) => i.severity === activeFilter)
+ : [];
+
+ const hasErrors = result && result.counts.error > 0;
+
+ return (
+
+ {/* Panel header */}
+
+
+
+ Accessibility Audit
+
+
+ {/* Status indicator */}
+ {isPending && (
+
+ Analyzing…
+
+ )}
+
+ {!isPending && result && (
+ <>
+ {result.passed ? (
+
+
+ Passed
+
+ ) : (
+
+ {result.issues.length} issue{result.issues.length !== 1 ? 's' : ''}
+
+ )}
+ >
+ )}
+
+
+
+ {/* Severity badges */}
+ {result && !result.passed && (
+
+ {result.counts.error > 0 && (
+
+ )}
+ {result.counts.warning > 0 && (
+
+ )}
+ {result.counts.info > 0 && (
+
+ )}
+
+ )}
+
+ {/* Refresh button */}
+ {onRunAudit && (
+
+ )}
+
+
+
+ {/* Content area */}
+
+ {/* Idle / no result yet */}
+ {!result && !isPending && (
+
+
+ Accessibility audit will run automatically as you edit the contract.
+
+
+ )}
+
+ {/* Pending */}
+ {isPending && !result && (
+
+ )}
+
+ {/* Results */}
+ {result && (
+ <>
+ {/* Passed state */}
+ {result.passed ? (
+
+
+
All checks passed
+
+ No accessibility or interface issues found in this contract.
+
+
+ ) : (
+ <>
+ {/* Filter tabs */}
+
+
+
+
+ {/* Issues list */}
+
+ {filteredIssues.length === 0 ? (
+
+ No {activeFilter === 'all' ? '' : activeFilter} issues found.
+
+ ) : (
+
+ {filteredIssues.map((issue, idx) => (
+
+ ))}
+
+ )}
+
+ >
+ )}
+ >
+ )}
+
+
+ {/* Footer legend */}
+
+
+
+ Error
+
+
+
+ Warning
+
+
+
+ Info
+
+
+ Interface Accessibility
+
+
+
+ );
+}
diff --git a/frontend/src/components/playground/CodeEditor.tsx b/frontend/src/components/playground/CodeEditor.tsx
index ff9f7490..e6b4a21b 100644
--- a/frontend/src/components/playground/CodeEditor.tsx
+++ b/frontend/src/components/playground/CodeEditor.tsx
@@ -1,17 +1,17 @@
'use client';
-import dynamic from 'next/dynamic';
-import type { OnMount } from '@monaco-editor/react';
-import type { editor } from 'monaco-editor';
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { ChevronRight, FileText } from 'lucide-react';
import type { CollaborationProvider } from '@/lib/collaboration/YjsProvider';
-import { extendRustLanguage } from '@/lib/editor/SorobanLanguage';
import { registerSorobanCompletion } from '@/lib/editor/SorobanCompletion';
import { registerSorobanHover } from '@/lib/editor/SorobanHover';
-import { createSorobanLinter } from '@/lib/editor/SorobanLinter';
+import { extendRustLanguage } from '@/lib/editor/SorobanLanguage';
import type { SorobanLinterInstance } from '@/lib/editor/SorobanLinter';
+import { createSorobanLinter } from '@/lib/editor/SorobanLinter';
import { THEME_COLORS } from '@/lib/theme/themeColors';
+import type { OnMount } from '@monaco-editor/react';
+import { ChevronRight, FileText } from 'lucide-react';
+import type { editor } from 'monaco-editor';
+import dynamic from 'next/dynamic';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
const Editor = dynamic(() => import('@monaco-editor/react'), {
ssr: false,
@@ -30,6 +30,8 @@ interface CodeEditorProps {
mobileMode?: boolean;
collaborationProvider?: CollaborationProvider;
settings?: MonacoEditorSettings;
+ /** Optional callback fired whenever the editor content changes */
+ onCodeChange?: (code: string) => void;
}
export interface MonacoEditorSettings {
@@ -115,6 +117,7 @@ export const CodeEditor: React.FC = ({
mobileMode = false,
collaborationProvider,
settings = { fontSize: 14, tabSize: 2, vimBindings: false },
+ onCodeChange,
}) => {
const [editorInstance, setEditorInstance] = useState(null);
const [code, setCode] = useState(DEFAULT_CODE);
@@ -131,8 +134,10 @@ export const CodeEditor: React.FC = ({
}, [collaborationProvider, roomName]);
const handleCodeChange = useCallback((value: string | undefined) => {
- setCode(value ?? '');
- }, []);
+ const newCode = value ?? '';
+ setCode(newCode);
+ onCodeChange?.(newCode);
+ }, [onCodeChange]);
const handleMonacoError = useCallback(() => {
setMonacoError(true);
diff --git a/frontend/src/components/playground/__tests__/AccessibilityAuditPanel.test.tsx b/frontend/src/components/playground/__tests__/AccessibilityAuditPanel.test.tsx
new file mode 100644
index 00000000..d280d9a6
--- /dev/null
+++ b/frontend/src/components/playground/__tests__/AccessibilityAuditPanel.test.tsx
@@ -0,0 +1,329 @@
+import type { AuditResult } from '@/lib/editor/SorobanAccessibilityAuditor';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AccessibilityAuditPanel } from '../AccessibilityAuditPanel';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const PASSING_RESULT: AuditResult = {
+ issues: [],
+ counts: { error: 0, warning: 0, info: 0 },
+ hasIssues: false,
+ passed: true,
+};
+
+const FAILING_RESULT: AuditResult = {
+ issues: [
+ {
+ rule: 'panic-only-error-handling',
+ severity: 'error',
+ message: '`panic!()` detected in contract code.',
+ suggestion: 'Return a Result instead.',
+ line: 5,
+ column: 5,
+ },
+ {
+ rule: 'missing-fn-doc',
+ severity: 'warning',
+ message: 'Public contract function `transfer` is missing a documentation comment.',
+ suggestion: 'Add a /// doc comment above `pub fn transfer`.',
+ line: 10,
+ },
+ {
+ rule: 'undocumented-error-enum',
+ severity: 'info',
+ message: 'Error enum `ContractError` has no documentation comment.',
+ suggestion: 'Add a /// comment above #[contracterror].',
+ line: 15,
+ },
+ ],
+ counts: { error: 1, warning: 1, info: 1 },
+ hasIssues: true,
+ passed: false,
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// ---------------------------------------------------------------------------
+// Rendering states
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — rendering', () => {
+ it('renders the panel heading', () => {
+ render();
+ expect(screen.getByRole('heading', { name: /accessibility audit/i })).toBeInTheDocument();
+ });
+
+ it('shows placeholder text when no result and not pending', () => {
+ render();
+ expect(
+ screen.getByText(/accessibility audit will run automatically/i)
+ ).toBeInTheDocument();
+ });
+
+ it('shows loading indicator when isPending is true and no result', () => {
+ render();
+ expect(screen.getByText(/auditing contract/i)).toBeInTheDocument();
+ });
+
+ it('shows passed state when result has no issues', () => {
+ render();
+ expect(screen.getByText(/all checks passed/i)).toBeInTheDocument();
+ });
+
+ it('shows issue count when result has issues', () => {
+ render();
+ expect(screen.getByText(/3 issues/i)).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Severity badges
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — severity badges', () => {
+ it('renders error badge with correct count', () => {
+ render();
+ // aria-label format: "1 error"
+ expect(screen.getByLabelText(/1 error/i)).toBeInTheDocument();
+ });
+
+ it('renders warning badge with correct count', () => {
+ render();
+ expect(screen.getByLabelText(/1 warning/i)).toBeInTheDocument();
+ });
+
+ it('renders info badge with correct count', () => {
+ render();
+ expect(screen.getByLabelText(/1 info/i)).toBeInTheDocument();
+ });
+
+ it('does not render badges when result has no issues', () => {
+ render();
+ expect(screen.queryByLabelText(/\d+ error/i)).not.toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Issue list
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — issue list', () => {
+ it('renders all issues in the "All" tab by default', () => {
+ render();
+ // Each issue row is a button with the issue message as text
+ expect(screen.getByText(/panic!.*detected/i)).toBeInTheDocument();
+ expect(screen.getByText(/transfer.*missing a documentation comment/i)).toBeInTheDocument();
+ expect(screen.getByText(/ContractError.*has no documentation/i)).toBeInTheDocument();
+ });
+
+ it('error issues are expanded by default (shows suggestion)', () => {
+ render();
+ // The error issue has defaultExpanded=true, so its suggestion should be visible
+ expect(screen.getByText(/Return a Result/i)).toBeInTheDocument();
+ });
+
+ it('non-error issues are collapsed by default', () => {
+ render();
+ // The warning suggestion should not be visible until expanded
+ expect(screen.queryByText(/Add a \/\/\/ doc comment above/i)).not.toBeInTheDocument();
+ });
+
+ it('expands a collapsed issue on click', () => {
+ render();
+ // Find the warning issue button
+ const warningBtn = screen.getByText(/transfer.*missing a documentation comment/i).closest('button');
+ expect(warningBtn).toBeDefined();
+ fireEvent.click(warningBtn!);
+ expect(screen.getByText(/Add a \/\/\/ doc comment above/i)).toBeInTheDocument();
+ });
+
+ it('collapses an expanded issue on second click', () => {
+ render();
+ // The error issue is already expanded; click to collapse
+ const errorBtn = screen.getByText(/panic!.*detected/i).closest('button');
+ fireEvent.click(errorBtn!);
+ expect(screen.queryByText(/Return a Result/i)).not.toBeInTheDocument();
+ });
+
+ it('shows line numbers in the issue metadata', () => {
+ render();
+ expect(screen.getByText(/Line 5/i)).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Filter tabs
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — filter tabs', () => {
+ it('renders All, Errors, Warnings, Info tabs', () => {
+ render();
+ expect(screen.getByRole('tab', { name: /all/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /errors/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /warnings/i })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: /info/i })).toBeInTheDocument();
+ });
+
+ it('the All tab is selected by default', () => {
+ render();
+ expect(screen.getByRole('tab', { name: /all/i })).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('clicking Errors tab filters to only error issues', () => {
+ render();
+ fireEvent.click(screen.getByRole('tab', { name: /errors/i }));
+ expect(screen.getByText(/panic!.*detected/i)).toBeInTheDocument();
+ expect(screen.queryByText(/transfer.*missing a documentation comment/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/ContractError.*has no documentation/i)).not.toBeInTheDocument();
+ });
+
+ it('clicking Warnings tab filters to only warning issues', () => {
+ render();
+ fireEvent.click(screen.getByRole('tab', { name: /warnings/i }));
+ expect(screen.queryByText(/panic!.*detected/i)).not.toBeInTheDocument();
+ expect(screen.getByText(/transfer.*missing a documentation comment/i)).toBeInTheDocument();
+ });
+
+ it('clicking Info tab filters to only info issues', () => {
+ render();
+ fireEvent.click(screen.getByRole('tab', { name: /info/i }));
+ expect(screen.queryByText(/panic!.*detected/i)).not.toBeInTheDocument();
+ expect(screen.getByText(/ContractError.*has no documentation/i)).toBeInTheDocument();
+ });
+
+ it('shows empty message when filter yields no issues', () => {
+ const onlyError: AuditResult = {
+ ...FAILING_RESULT,
+ issues: [FAILING_RESULT.issues[0]],
+ counts: { error: 1, warning: 0, info: 0 },
+ };
+ render();
+ fireEvent.click(screen.getByRole('tab', { name: /warnings/i }));
+ expect(screen.getByText(/no warning issues found/i)).toBeInTheDocument();
+ });
+
+ it('resets to "All" tab when a new result is received', async () => {
+ const { rerender } = render(
+
+ );
+ fireEvent.click(screen.getByRole('tab', { name: /errors/i }));
+ expect(screen.getByRole('tab', { name: /errors/i })).toHaveAttribute('aria-selected', 'true');
+
+ rerender();
+ // Passing result shows "All checks passed" — no tabs rendered
+ expect(screen.getByText(/all checks passed/i)).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Refresh button
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — refresh button', () => {
+ it('renders a re-run button when onRunAudit is provided', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: /re-run accessibility audit/i })).toBeInTheDocument();
+ });
+
+ it('calls onRunAudit when the refresh button is clicked', () => {
+ const onRunAudit = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /re-run accessibility audit/i }));
+ expect(onRunAudit).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the refresh button while isPending is true', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: /re-run accessibility audit/i })).toBeDisabled();
+ });
+
+ it('does not render re-run button when onRunAudit is not provided', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /re-run accessibility audit/i })).not.toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Accessibility (ARIA) attributes
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — ARIA', () => {
+ it('root element is a with aria-labelledby pointing to the heading', () => {
+ const { container } = render(
+
+ );
+ const section = container.querySelector('section');
+ expect(section).toBeInTheDocument();
+ const labelledby = section!.getAttribute('aria-labelledby');
+ expect(labelledby).toBeTruthy();
+ const heading = document.getElementById(labelledby!);
+ expect(heading).toBeInTheDocument();
+ expect(heading!.textContent).toMatch(/accessibility audit/i);
+ });
+
+ it('issue rows have aria-expanded attribute', () => {
+ render();
+ // The error issue button should have aria-expanded=true
+ const errorBtn = screen.getByText(/panic!.*detected/i).closest('button');
+ expect(errorBtn).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('the tablist has an accessible label', () => {
+ render();
+ expect(
+ screen.getByRole('tablist', { name: /filter audit issues by severity/i })
+ ).toBeInTheDocument();
+ });
+
+ it('severity badges have aria-label', () => {
+ render();
+ // aria-label is "1 errors" or "1 error" depending on singular/plural
+ const badgesContainer = screen.getByRole('status', { name: /issue counts by severity/i });
+ expect(badgesContainer).toBeInTheDocument();
+ });
+
+ it('the issues list has a descriptive aria-label', () => {
+ render();
+ expect(screen.getByRole('list', { name: /3 audit issues/i })).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Custom className prop
+// ---------------------------------------------------------------------------
+
+describe('AccessibilityAuditPanel — className', () => {
+ it('applies a custom className to the root section', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('section')).toHaveClass('my-custom-class');
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useAccessibilityAudit.test.ts b/frontend/src/hooks/__tests__/useAccessibilityAudit.test.ts
new file mode 100644
index 00000000..84ce98c0
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useAccessibilityAudit.test.ts
@@ -0,0 +1,191 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { useAccessibilityAudit } from '../useAccessibilityAudit';
+
+// We test the hook in isolation by using fake timers to control the debounce.
+
+const CLEAN_SOURCE = `#![no_std]
+use soroban_sdk::{contract, contractimpl, Env, Symbol};
+/// A simple contract.
+#[contract]
+pub struct HelloContract;
+#[contractimpl]
+impl HelloContract {
+ /// Returns a greeting.
+ pub fn hello(env: Env) -> Symbol {
+ Symbol::new(&env, "hello")
+ }
+}
+`;
+
+const DIRTY_SOURCE = `use soroban_sdk::{};\npub struct Missing;\nfn f() { panic!("bad"); }`;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('useAccessibilityAudit', () => {
+ it('starts with result=null before the debounce fires', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+ expect(result.current.result).toBeNull();
+ });
+
+ it('isPending is true while debounce is in-flight', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+ expect(result.current.isPending).toBe(true);
+ });
+
+ it('result is populated after debounce fires', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(result.current.result).not.toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('result.passed is true for a clean source', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(CLEAN_SOURCE, { debounceMs: 400 })
+ );
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(result.current.result?.passed).toBe(true);
+ });
+
+ it('result.hasIssues is true for a dirty source', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(result.current.result?.hasIssues).toBe(true);
+ });
+
+ it('debounces rapid source changes — only runs audit once', () => {
+ let source = DIRTY_SOURCE;
+ const { result, rerender } = renderHook(() =>
+ useAccessibilityAudit(source, { debounceMs: 400 })
+ );
+
+ // Simulate rapid typing
+ source = DIRTY_SOURCE + '\n// change 1';
+ rerender();
+ source = DIRTY_SOURCE + '\n// change 2';
+ rerender();
+ source = DIRTY_SOURCE + '\n// change 3';
+ rerender();
+
+ // Should still be pending — timer not fired yet
+ expect(result.current.isPending).toBe(true);
+
+ // Fire the single debounced timer
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+
+ // Should have run exactly once after the last change
+ expect(result.current.result).not.toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('re-runs audit when source changes after initial run', () => {
+ let source = DIRTY_SOURCE;
+ const { result, rerender } = renderHook(() =>
+ useAccessibilityAudit(source, { debounceMs: 400 })
+ );
+
+ act(() => { vi.advanceTimersByTime(400); });
+ const firstResult = result.current.result;
+
+ // Change to a clean source
+ source = CLEAN_SOURCE;
+ rerender();
+
+ act(() => { vi.advanceTimersByTime(400); });
+ const secondResult = result.current.result;
+
+ expect(firstResult?.passed).toBe(false);
+ expect(secondResult?.passed).toBe(true);
+ });
+
+ it('runAudit triggers an immediate (non-debounced) audit', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+
+ // Don't advance timers; call runAudit directly
+ act(() => {
+ result.current.runAudit();
+ });
+
+ expect(result.current.result).not.toBeNull();
+ });
+
+ it('does not run audit when enabled is false', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400, enabled: false })
+ );
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(result.current.result).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('clears result when enabled changes to false', () => {
+ let enabled = true;
+ const { result, rerender } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400, enabled })
+ );
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(result.current.result).not.toBeNull();
+
+ enabled = false;
+ rerender();
+ expect(result.current.result).toBeNull();
+ expect(result.current.isPending).toBe(false);
+ });
+
+ it('getIssuesBySeverity returns only error issues', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+ act(() => { vi.advanceTimersByTime(400); });
+ const errors = result.current.getIssuesBySeverity('error');
+ expect(errors.every((i) => i.severity === 'error')).toBe(true);
+ });
+
+ it('getIssuesBySeverity returns empty array when result is null', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE, { debounceMs: 400 })
+ );
+ // Don't advance timers — result is still null
+ const errors = result.current.getIssuesBySeverity('error');
+ expect(errors).toEqual([]);
+ });
+
+ it('uses 400ms debounce by default', () => {
+ const { result } = renderHook(() =>
+ useAccessibilityAudit(DIRTY_SOURCE)
+ );
+
+ // At 399ms — still pending
+ act(() => { vi.advanceTimersByTime(399); });
+ expect(result.current.isPending).toBe(true);
+
+ // At 400ms — should have resolved
+ act(() => { vi.advanceTimersByTime(1); });
+ expect(result.current.result).not.toBeNull();
+ });
+});
diff --git a/frontend/src/hooks/useAccessibilityAudit.ts b/frontend/src/hooks/useAccessibilityAudit.ts
new file mode 100644
index 00000000..2e26db9d
--- /dev/null
+++ b/frontend/src/hooks/useAccessibilityAudit.ts
@@ -0,0 +1,84 @@
+'use client';
+
+import {
+ auditSorobanSource,
+ type AuditResult,
+ type AuditSeverity,
+} from '@/lib/editor/SorobanAccessibilityAuditor';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+export interface UseAccessibilityAuditOptions {
+ /**
+ * Debounce in milliseconds before re-running the audit after a source change.
+ * Defaults to 400 ms to avoid blocking the editor on every keystroke.
+ */
+ debounceMs?: number;
+ /** When false, the audit will not run. Defaults to true. */
+ enabled?: boolean;
+}
+
+export interface UseAccessibilityAuditReturn {
+ /** Latest audit result; null before the first audit completes */
+ result: AuditResult | null;
+ /** True while a debounced audit is pending */
+ isPending: boolean;
+ /** Manually trigger an immediate (non-debounced) re-audit */
+ runAudit: () => void;
+ /** Filter the current result down to a single severity */
+ getIssuesBySeverity: (severity: AuditSeverity) => AuditResult['issues'];
+}
+
+export function useAccessibilityAudit(
+ source: string,
+ {
+ debounceMs = 400,
+ enabled = true,
+ }: UseAccessibilityAuditOptions = {}
+): UseAccessibilityAuditReturn {
+ const [result, setResult] = useState(null);
+ const [isPending, setIsPending] = useState(false);
+ const timerRef = useRef | null>(null);
+ const sourceRef = useRef(source);
+ sourceRef.current = source;
+
+ const runAudit = useCallback(() => {
+ if (!enabled) return;
+ setIsPending(false);
+ const auditResult = auditSorobanSource(sourceRef.current);
+ setResult(auditResult);
+ }, [enabled]);
+
+ useEffect(() => {
+ if (!enabled) {
+ setResult(null);
+ setIsPending(false);
+ return;
+ }
+
+ setIsPending(true);
+
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ timerRef.current = setTimeout(() => {
+ runAudit();
+ }, debounceMs);
+
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+ };
+ }, [source, debounceMs, enabled, runAudit]);
+
+ const getIssuesBySeverity = useCallback(
+ (severity: AuditSeverity) => {
+ if (!result) return [];
+ return result.issues.filter((i) => i.severity === severity);
+ },
+ [result]
+ );
+
+ return { result, isPending, runAudit, getIssuesBySeverity };
+}
diff --git a/frontend/src/lib/editor/SorobanAccessibilityAuditor.ts b/frontend/src/lib/editor/SorobanAccessibilityAuditor.ts
new file mode 100644
index 00000000..9aafd782
--- /dev/null
+++ b/frontend/src/lib/editor/SorobanAccessibilityAuditor.ts
@@ -0,0 +1,619 @@
+/**
+ * SorobanAccessibilityAuditor
+ *
+ * Audits Soroban smart contract source code for accessibility-related issues.
+ * "Accessibility" here covers the contract's *interface accessibility* for
+ * human developers and tools:
+ *
+ * - Missing documentation on public contract functions
+ * - Undocumented / opaque error codes
+ * - Events lacking semantic topic labels
+ * - Public functions with no parameter names (hurts readability & tooling)
+ * - Storage keys that are raw bytes rather than readable Symbols
+ * - Contract structs without the required #[contract] attribute
+ * - Panic-only error handling (should use Result / Error types instead)
+ *
+ * Each issue carries a severity (error | warning | info), a human-readable
+ * message, an optional suggestion, and the line number where it was detected.
+ */
+
+export type AuditSeverity = 'error' | 'warning' | 'info';
+
+export interface AuditIssue {
+ /** Unique rule identifier, e.g. "missing-doc-comment" */
+ rule: string;
+ severity: AuditSeverity;
+ /** Human-readable description of the issue */
+ message: string;
+ /** Actionable fix hint shown to the user */
+ suggestion: string;
+ /** 1-indexed line number in the source */
+ line: number;
+ /** Optional column (1-indexed) for precise location */
+ column?: number;
+}
+
+export interface AuditResult {
+ issues: AuditIssue[];
+ /** Total counts by severity for quick summary badges */
+ counts: {
+ error: number;
+ warning: number;
+ info: number;
+ };
+ /** Whether any issues were found */
+ hasIssues: boolean;
+ /** True only when all checks pass with zero issues */
+ passed: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/** Returns true when a line (or lines just before it) contains a doc comment */
+function hasDocComment(lines: string[], lineIndex: number): boolean {
+ for (let i = lineIndex - 1; i >= 0; i--) {
+ const trimmed = lines[i].trim();
+ if (trimmed === '') continue;
+ // Block doc comment above — /// or /** … */
+ if (trimmed.startsWith('///') || trimmed.startsWith('/**') || trimmed.startsWith('*')) {
+ return true;
+ }
+ break;
+ }
+ return false;
+}
+
+/** Extract the name from a `pub fn name(` declaration */
+function extractFnName(line: string): string | null {
+ const match = line.match(/pub\s+fn\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*[\(<]/);
+ return match ? match[1] : null;
+}
+
+/** Detect whether we are inside a #[contractimpl] block (very rough heuristic) */
+function findContractImplRanges(lines: string[]): Array<{ start: number; end: number }> {
+ const ranges: Array<{ start: number; end: number }> = [];
+ let depth = 0;
+ let start = -1;
+ let inContractImpl = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ const trimmed = lines[i].trim();
+
+ if (/#\[\s*contractimpl\s*\]/.test(trimmed)) {
+ inContractImpl = true;
+ }
+
+ if (inContractImpl) {
+ for (const ch of lines[i]) {
+ if (ch === '{') {
+ if (depth === 0) start = i;
+ depth++;
+ } else if (ch === '}') {
+ depth--;
+ if (depth === 0 && start !== -1) {
+ ranges.push({ start, end: i });
+ inContractImpl = false;
+ start = -1;
+ }
+ }
+ }
+ }
+ }
+
+ return ranges;
+}
+
+/** Return true if the given lineIndex falls inside any contractimpl block */
+function isInsideContractImpl(
+ lineIndex: number,
+ ranges: Array<{ start: number; end: number }>
+): boolean {
+ return ranges.some((r) => lineIndex >= r.start && lineIndex <= r.end);
+}
+
+// ---------------------------------------------------------------------------
+// Rule implementations
+// ---------------------------------------------------------------------------
+
+/**
+ * RULE: missing-fn-doc
+ * Every `pub fn` inside a #[contractimpl] block should have a doc comment.
+ */
+function checkMissingFnDoc(lines: string[], implRanges: Array<{ start: number; end: number }>): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (!/pub\s+fn\s+/.test(line)) continue;
+ if (!isInsideContractImpl(i, implRanges)) continue;
+
+ const fnName = extractFnName(line);
+ if (!fnName) continue;
+
+ // Constructor-style functions named "new" or "__constructor" are exempt
+ if (fnName === 'new' || fnName === '__constructor') continue;
+
+ if (!hasDocComment(lines, i)) {
+ issues.push({
+ rule: 'missing-fn-doc',
+ severity: 'warning',
+ message: `Public contract function \`${fnName}\` is missing a documentation comment.`,
+ suggestion: `Add a /// doc comment above \`pub fn ${fnName}\` to describe its behaviour, parameters, and return value.`,
+ line: i + 1,
+ column: line.indexOf('pub') + 1,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: opaque-error-code
+ * Error enums decorated with #[contracterror] should have descriptive variant
+ * names — not raw numbers or single-letter identifiers.
+ */
+function checkOpaqueErrorCodes(lines: string[]): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+ let inContractError = false;
+ let braceDepth = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const trimmed = lines[i].trim();
+
+ if (/#\[\s*contracterror\s*\]/.test(trimmed)) {
+ inContractError = true;
+ }
+
+ if (inContractError) {
+ for (const ch of lines[i]) {
+ if (ch === '{') braceDepth++;
+ else if (ch === '}') {
+ braceDepth--;
+ if (braceDepth === 0) inContractError = false;
+ }
+ }
+
+ // Variant lines look like: SomeName = 1,
+ const variantMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\s*,?$/);
+ if (variantMatch) {
+ const variantName = variantMatch[1];
+ // Flag variants that are just one or two chars, or that look like E1 / Err1
+ if (variantName.length <= 2 || /^E\d+$|^Err\d+$/i.test(variantName)) {
+ issues.push({
+ rule: 'opaque-error-code',
+ severity: 'warning',
+ message: `Error variant \`${variantName}\` is not descriptive. Opaque error codes make contracts hard to integrate.`,
+ suggestion: `Rename \`${variantName}\` to something descriptive like \`InsufficientBalance\`, \`Unauthorized\`, or \`NotFound\`.`,
+ line: i + 1,
+ column: lines[i].indexOf(variantName) + 1,
+ });
+ }
+ }
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: undocumented-error-enum
+ * A #[contracterror] enum without a doc comment makes integrators guess the
+ * meaning of each code.
+ */
+function checkUndocumentedErrorEnum(lines: string[]): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const trimmed = lines[i].trim();
+ if (!/#\[\s*contracterror\s*\]/.test(trimmed)) continue;
+
+ // Find the enum line that follows
+ for (let j = i + 1; j < lines.length && j <= i + 4; j++) {
+ if (/pub\s+enum\s+/.test(lines[j])) {
+ if (!hasDocComment(lines, i)) {
+ const enumMatch = lines[j].match(/pub\s+enum\s+([A-Za-z_][A-Za-z0-9_]*)/);
+ const enumName = enumMatch ? enumMatch[1] : 'error enum';
+ issues.push({
+ rule: 'undocumented-error-enum',
+ severity: 'info',
+ message: `Error enum \`${enumName}\` has no documentation comment.`,
+ suggestion: `Add a /// comment above #[contracterror] explaining when each variant is returned.`,
+ line: i + 1,
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: event-missing-topics
+ * Calls to env.events().publish() should include at least two topics so that
+ * off-chain indexers can filter by event type.
+ *
+ * We parse the topics tuple by counting balanced parentheses so that inner
+ * calls like Symbol::new(&env, "name") don't fool the comma counter.
+ */
+function checkEventMissingTopics(lines: string[]): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Match env.events().publish( or events().publish(
+ if (!/(env\.)?events\s*\(\s*\)\s*\.publish\s*\(/.test(line)) continue;
+
+ // Find the opening paren of .publish( and then the first ( after it,
+ // which begins the topics tuple. We count balanced parens to find where
+ // the tuple ends and count top-level commas.
+ const publishCallIdx = line.search(/\.publish\s*\(/);
+ if (publishCallIdx === -1) continue;
+
+ // Advance past ".publish("
+ let pos = publishCallIdx;
+ while (pos < line.length && line[pos] !== '(') pos++;
+ pos++; // skip the opening paren of publish(
+
+ // Skip whitespace to find the start of the topics tuple "("
+ while (pos < line.length && line[pos] === ' ') pos++;
+
+ if (pos >= line.length || line[pos] !== '(') continue; // no tuple literal on this line
+
+ // Walk the tuple, counting top-level commas (ignoring nested parens)
+ let tupleDepth = 0;
+ let topLevelCommas = 0;
+ let nonEmpty = false;
+
+ for (let k = pos; k < line.length; k++) {
+ const ch = line[k];
+ if (ch === '(') {
+ tupleDepth++;
+ } else if (ch === ')') {
+ tupleDepth--;
+ if (tupleDepth === 0) break; // end of tuple
+ } else if (ch === ',' && tupleDepth === 1) {
+ topLevelCommas++;
+ } else if (tupleDepth >= 1 && ch !== ' ' && ch !== '\t') {
+ nonEmpty = true;
+ }
+ }
+
+ // topLevelCommas+1 gives us the number of elements (trailing comma = same count)
+ // A single-topic tuple like (topic,) has 1 comma but only 1 element.
+ // We treat topLevelCommas >= 1 as having 2+ topics only when nonEmpty.
+ // For a trailing-comma tuple: (a,) → 1 comma but 1 element.
+ // For a two-element tuple: (a, b) → 1 comma, 2 elements.
+ // Distinction: does the last comma have content after it before the closing paren?
+ // Simplest heuristic: trim the inner content and count non-empty parts by splitting on comma.
+
+ // Re-extract the raw topics content for a simple split
+ let tupleClosed = false;
+ let depth2 = 0;
+ let tupleContent = '';
+ let tupleStarted = false;
+ for (let k = pos; k < line.length; k++) {
+ const ch = line[k];
+ if (ch === '(') {
+ depth2++;
+ if (depth2 === 1) { tupleStarted = true; continue; } // skip outer tuple's own (
+ } else if (ch === ')') {
+ depth2--;
+ if (depth2 === 0) { tupleClosed = true; break; }
+ }
+ if (tupleStarted && depth2 >= 1) tupleContent += ch;
+ }
+
+ if (!tupleClosed) continue;
+
+ // Count top-level items within tupleContent, tracking only () depth
+ let itemDepth = 0;
+ let itemCommas = 0;
+ let hasContent = false;
+ for (let k = 0; k < tupleContent.length; k++) {
+ const ch = tupleContent[k];
+ if (ch === '(') {
+ itemDepth++;
+ } else if (ch === ')') {
+ if (itemDepth > 0) itemDepth--;
+ } else if (ch === ',' && itemDepth === 0) {
+ itemCommas++;
+ } else if (ch !== ' ' && ch !== '\t' && itemDepth === 0) {
+ hasContent = true;
+ }
+ }
+
+ // Trailing comma means itemCommas items (e.g., "a," → 1 comma, 1 item)
+ // No trailing comma means itemCommas+1 items (e.g., "a, b" → 1 comma, 2 items)
+ const lastNonSpace = tupleContent.trimEnd().slice(-1);
+ const itemCount = lastNonSpace === ',' ? itemCommas : (hasContent ? itemCommas + 1 : 0);
+
+ if (itemCount < 2) {
+ issues.push({
+ rule: 'event-missing-topics',
+ severity: 'info',
+ message: `Event published with fewer than 2 topics. Indexers and clients rely on topics for event filtering.`,
+ suggestion: `Provide at least two topics: an event-type Symbol and a relevant identifier, e.g. (Symbol::new(&env, "transfer"), from_address).`,
+ line: i + 1,
+ column: publishCallIdx + 1,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: raw-bytes-storage-key
+ * Using raw Bytes or BytesN as storage keys is hard to debug. Prefer Symbol.
+ */
+function checkRawBytesStorageKey(lines: string[]): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // storage().instance|persistent|temporary().set(Bytes::... or BytesN::...
+ if (!/(instance|persistent|temporary)\s*\(\s*\)\s*\.\s*(get|set|has|remove)\s*\(/.test(line)) {
+ continue;
+ }
+
+ if (/Bytes\s*::/.test(line) || /BytesN\s*::/.test(line)) {
+ issues.push({
+ rule: 'raw-bytes-storage-key',
+ severity: 'info',
+ message: `Using raw Bytes/BytesN as a storage key reduces readability and makes debugging harder.`,
+ suggestion: `Use \`Symbol::new(&env, "key_name")\` or a #[contracttype] enum as your storage key instead.`,
+ line: i + 1,
+ column: (line.match(/Bytes/)?.index ?? 0) + 1,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: panic-only-error-handling
+ * Using panic!() or unwrap() in contract code is poor practice — panics cannot
+ * be caught by callers. Return Result/Error types instead.
+ */
+function checkPanicOnlyErrorHandling(lines: string[]): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Skip comment lines
+ const trimmed = line.trimStart();
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
+ continue;
+ }
+
+ const panicMatch = line.match(/\bpanic!\s*\(/);
+ if (panicMatch) {
+ issues.push({
+ rule: 'panic-only-error-handling',
+ severity: 'error',
+ message: `\`panic!()\` detected in contract code. Panics cannot be caught by callers and prevent graceful error handling.`,
+ suggestion: `Return a \`Result\` or use \`env.panic_with_error()\` to propagate typed errors that clients can handle.`,
+ line: i + 1,
+ column: (panicMatch.index ?? 0) + 1,
+ });
+ }
+
+ // .unwrap() in a context that looks like a contract function
+ const unwrapMatch = line.match(/\.unwrap\s*\(\s*\)/);
+ if (unwrapMatch) {
+ issues.push({
+ rule: 'panic-only-error-handling',
+ severity: 'warning',
+ message: `\`.unwrap()\` can panic on \`None\`/\`Err\` and will abort the contract invocation.`,
+ suggestion: `Replace \`.unwrap()\` with \`.unwrap_or_default()\`, \`.unwrap_or(fallback)\`, or propagate the error with \`?\`.`,
+ line: i + 1,
+ column: (unwrapMatch.index ?? 0) + 1,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: missing-contract-attribute
+ * A public struct inside a file that uses soroban_sdk imports but is missing
+ * #[contract] is likely a contract that will not compile.
+ */
+function checkMissingContractAttribute(lines: string[]): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+ const hasSorobanImport = lines.some((l) => /use\s+soroban_sdk::/.test(l));
+ if (!hasSorobanImport) return issues;
+
+ for (let i = 0; i < lines.length; i++) {
+ if (!/pub\s+struct\s+[A-Z][A-Za-z0-9_]*\s*[;{]/.test(lines[i])) continue;
+
+ // Look backward for #[contract]
+ let found = false;
+ for (let j = i - 1; j >= 0; j--) {
+ const prev = lines[j].trim();
+ if (prev === '' || prev.startsWith('//') || prev.startsWith('/*') || prev.startsWith('*')) {
+ continue;
+ }
+ if (/#\[\s*contract\s*\]/.test(prev) || /#\[\s*contracttype\s*\]/.test(prev)) {
+ found = true;
+ }
+ break;
+ }
+
+ if (!found) {
+ const structMatch = lines[i].match(/pub\s+struct\s+([A-Z][A-Za-z0-9_]*)/);
+ const structName = structMatch ? structMatch[1] : 'struct';
+ issues.push({
+ rule: 'missing-contract-attribute',
+ severity: 'error',
+ message: `Soroban contract struct \`${structName}\` is missing the \`#[contract]\` attribute.`,
+ suggestion: `Add \`#[contract]\` on the line directly above \`pub struct ${structName}\`.`,
+ line: i + 1,
+ column: lines[i].indexOf('pub') + 1,
+ });
+ }
+ }
+
+ return issues;
+}
+
+/**
+ * RULE: unnamed-fn-params
+ * Public contract functions with unnamed parameters (using `_` as the sole
+ * name) make the interface harder to understand for integrators.
+ */
+function checkUnnamedFnParams(
+ lines: string[],
+ implRanges: Array<{ start: number; end: number }>
+): AuditIssue[] {
+ const issues: AuditIssue[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ if (!isInsideContractImpl(i, implRanges)) continue;
+
+ const line = lines[i];
+ if (!/pub\s+fn\s+/.test(line)) continue;
+
+ // Collect full signature — it may span multiple lines
+ let sigLines = line;
+ for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
+ sigLines += ' ' + lines[j];
+ if (sigLines.includes('{') || sigLines.includes(';')) break;
+ }
+
+ const fnName = extractFnName(line);
+ if (!fnName) continue;
+
+ // Extract param list between first ( and the matching )
+ const paramStart = sigLines.indexOf('(');
+ let depth = 0;
+ let paramEnd = -1;
+ for (let k = paramStart; k < sigLines.length; k++) {
+ if (sigLines[k] === '(') depth++;
+ else if (sigLines[k] === ')') {
+ depth--;
+ if (depth === 0) { paramEnd = k; break; }
+ }
+ }
+
+ if (paramStart === -1 || paramEnd === -1) continue;
+
+ const params = sigLines.slice(paramStart + 1, paramEnd);
+
+ // Split by comma but be mindful of nested generics
+ const paramParts: string[] = [];
+ let current = '';
+ let angleDepth = 0;
+ for (const ch of params) {
+ if (ch === '<') angleDepth++;
+ else if (ch === '>') angleDepth--;
+ else if (ch === ',' && angleDepth === 0) {
+ paramParts.push(current.trim());
+ current = '';
+ continue;
+ }
+ current += ch;
+ }
+ if (current.trim()) paramParts.push(current.trim());
+
+ for (const param of paramParts) {
+ // Skip `self`, `&self`, `&mut self`, `env: Env`
+ if (/^&?\s*(mut\s+)?self$/.test(param.trim())) continue;
+ if (/^env\s*:\s*Env/.test(param.trim())) continue;
+
+ // A param is underscore-only when the name part (before `:`) is `_`
+ const colonIdx = param.indexOf(':');
+ if (colonIdx === -1) continue;
+ const name = param.slice(0, colonIdx).trim();
+
+ if (name === '_') {
+ issues.push({
+ rule: 'unnamed-fn-params',
+ severity: 'info',
+ message: `Public function \`${fnName}\` has an unnamed parameter (\`_\`). Unnamed parameters hurt API readability for integrators.`,
+ suggestion: `Replace \`_\` with a descriptive parameter name that reflects the argument's purpose.`,
+ line: i + 1,
+ column: line.indexOf('pub') + 1,
+ });
+ break; // one issue per function is enough
+ }
+ }
+ }
+
+ return issues;
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Run the full accessibility audit on a Soroban contract source string.
+ * Returns a structured `AuditResult` with all found issues and summary counts.
+ */
+export function auditSorobanSource(source: string): AuditResult {
+ if (!source || source.trim() === '') {
+ return { issues: [], counts: { error: 0, warning: 0, info: 0 }, hasIssues: false, passed: true };
+ }
+
+ const lines = source.split('\n');
+ const implRanges = findContractImplRanges(lines);
+
+ const allIssues: AuditIssue[] = [
+ ...checkMissingContractAttribute(lines),
+ ...checkMissingFnDoc(lines, implRanges),
+ ...checkOpaqueErrorCodes(lines),
+ ...checkUndocumentedErrorEnum(lines),
+ ...checkEventMissingTopics(lines),
+ ...checkRawBytesStorageKey(lines),
+ ...checkPanicOnlyErrorHandling(lines),
+ ...checkUnnamedFnParams(lines, implRanges),
+ ];
+
+ // Sort by line number for predictable display order
+ allIssues.sort((a, b) => a.line - b.line || a.severity.localeCompare(b.severity));
+
+ const counts = {
+ error: allIssues.filter((i) => i.severity === 'error').length,
+ warning: allIssues.filter((i) => i.severity === 'warning').length,
+ info: allIssues.filter((i) => i.severity === 'info').length,
+ };
+
+ return {
+ issues: allIssues,
+ counts,
+ hasIssues: allIssues.length > 0,
+ passed: allIssues.length === 0,
+ };
+}
+
+/**
+ * Convenience helper: returns only issues of the given severity.
+ */
+export function filterIssuesBySeverity(result: AuditResult, severity: AuditSeverity): AuditIssue[] {
+ return result.issues.filter((i) => i.severity === severity);
+}
+
+/**
+ * Returns a plain-text summary suitable for screen-readers or the terminal.
+ */
+export function formatAuditSummary(result: AuditResult): string {
+ if (result.passed) {
+ return 'Accessibility audit passed. No issues found.';
+ }
+ const parts: string[] = [];
+ if (result.counts.error > 0) parts.push(`${result.counts.error} error${result.counts.error !== 1 ? 's' : ''}`);
+ if (result.counts.warning > 0) parts.push(`${result.counts.warning} warning${result.counts.warning !== 1 ? 's' : ''}`);
+ if (result.counts.info > 0) parts.push(`${result.counts.info} info${result.counts.info !== 1 ? 's' : ''}`);
+ return `Accessibility audit: ${parts.join(', ')}.`;
+}
diff --git a/frontend/src/lib/editor/__tests__/SorobanAccessibilityAuditor.test.ts b/frontend/src/lib/editor/__tests__/SorobanAccessibilityAuditor.test.ts
new file mode 100644
index 00000000..9e8f7db2
--- /dev/null
+++ b/frontend/src/lib/editor/__tests__/SorobanAccessibilityAuditor.test.ts
@@ -0,0 +1,488 @@
+import { describe, expect, it } from 'vitest';
+import {
+ auditSorobanSource,
+ filterIssuesBySeverity,
+ formatAuditSummary
+} from '../SorobanAccessibilityAuditor';
+
+// ---------------------------------------------------------------------------
+// Helper builders
+// ---------------------------------------------------------------------------
+
+/** A minimal valid Soroban contract that should pass all checks */
+const CLEAN_CONTRACT = `#![no_std]
+use soroban_sdk::{contract, contractimpl, Env, Symbol};
+
+/// A simple hello-world Soroban contract.
+#[contract]
+pub struct HelloContract;
+
+#[contractimpl]
+impl HelloContract {
+ /// Returns a greeting symbol.
+ pub fn hello(_env: Env) -> Symbol {
+ Symbol::new(&_env, "hello")
+ }
+}
+`;
+
+// ---------------------------------------------------------------------------
+// auditSorobanSource — empty / null-ish inputs
+// ---------------------------------------------------------------------------
+
+describe('auditSorobanSource — empty input', () => {
+ it('returns no issues for an empty string', () => {
+ const result = auditSorobanSource('');
+ expect(result.passed).toBe(true);
+ expect(result.hasIssues).toBe(false);
+ expect(result.issues).toHaveLength(0);
+ });
+
+ it('returns no issues for whitespace-only input', () => {
+ const result = auditSorobanSource(' \n \t ');
+ expect(result.passed).toBe(true);
+ });
+
+ it('counts are all zero for empty input', () => {
+ const result = auditSorobanSource('');
+ expect(result.counts).toEqual({ error: 0, warning: 0, info: 0 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: missing-contract-attribute
+// ---------------------------------------------------------------------------
+
+describe('rule: missing-contract-attribute', () => {
+ it('flags a PascalCase struct in a soroban file without #[contract]', () => {
+ const source = `use soroban_sdk::{contract};\npub struct MyContract;`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-contract-attribute');
+ expect(issue).toBeDefined();
+ expect(issue!.severity).toBe('error');
+ expect(issue!.message).toContain('MyContract');
+ });
+
+ it('does not flag when #[contract] is present directly above', () => {
+ const source = `use soroban_sdk::{contract};\n#[contract]\npub struct MyContract;`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-contract-attribute');
+ expect(issue).toBeUndefined();
+ });
+
+ it('does not flag a struct in a file with no soroban import', () => {
+ const source = `pub struct Foo;\npub struct Bar { x: u32 }`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-contract-attribute');
+ expect(issue).toBeUndefined();
+ });
+
+ it('does not flag a #[contracttype] struct', () => {
+ const source = `use soroban_sdk::{contract};\n#[contracttype]\npub struct DataKey;`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-contract-attribute');
+ expect(issue).toBeUndefined();
+ });
+
+ it('reports the correct line number', () => {
+ const source = `use soroban_sdk::{};\n\n\npub struct Foo;`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-contract-attribute');
+ expect(issue?.line).toBe(4);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: missing-fn-doc
+// ---------------------------------------------------------------------------
+
+describe('rule: missing-fn-doc', () => {
+ it('flags a public function inside #[contractimpl] without a doc comment', () => {
+ const source = `use soroban_sdk::{contract, contractimpl, Env};
+#[contract]
+pub struct C;
+#[contractimpl]
+impl C {
+ pub fn transfer(env: Env) {}
+}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-fn-doc');
+ expect(issue).toBeDefined();
+ expect(issue!.message).toContain('transfer');
+ });
+
+ it('does not flag when a /// comment is directly above the function', () => {
+ const source = `use soroban_sdk::{contract, contractimpl, Env};
+#[contract]
+pub struct C;
+#[contractimpl]
+impl C {
+ /// Transfers tokens.
+ pub fn transfer(env: Env) {}
+}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-fn-doc');
+ expect(issue).toBeUndefined();
+ });
+
+ it('does not flag functions outside a #[contractimpl] block', () => {
+ const source = `use soroban_sdk::{};\nfn helper() {}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'missing-fn-doc');
+ expect(issue).toBeUndefined();
+ });
+
+ it('flags multiple undocumented functions', () => {
+ const source = `use soroban_sdk::{contract, contractimpl, Env};
+#[contract]
+pub struct C;
+#[contractimpl]
+impl C {
+ pub fn alpha(env: Env) {}
+ pub fn beta(env: Env) {}
+}`;
+ const result = auditSorobanSource(source);
+ const issues = result.issues.filter((i) => i.rule === 'missing-fn-doc');
+ expect(issues.length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: opaque-error-code
+// ---------------------------------------------------------------------------
+
+describe('rule: opaque-error-code', () => {
+ it('flags a single-letter error variant', () => {
+ const source = `#[contracterror]\npub enum MyError {\n E = 1,\n}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'opaque-error-code');
+ expect(issue).toBeDefined();
+ expect(issue!.message).toContain('E');
+ });
+
+ it('flags an Err1-style variant', () => {
+ const source = `#[contracterror]\npub enum MyError {\n Err1 = 1,\n Err2 = 2,\n}`;
+ const result = auditSorobanSource(source);
+ const issues = result.issues.filter((i) => i.rule === 'opaque-error-code');
+ expect(issues.length).toBe(2);
+ });
+
+ it('does not flag descriptive variant names', () => {
+ const source = `#[contracterror]\npub enum ContractError {\n InsufficientBalance = 1,\n Unauthorized = 2,\n}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'opaque-error-code');
+ expect(issue).toBeUndefined();
+ });
+
+ it('does not flag variants outside a #[contracterror] block', () => {
+ const source = `pub enum SomeEnum {\n E = 1,\n}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'opaque-error-code');
+ expect(issue).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: undocumented-error-enum
+// ---------------------------------------------------------------------------
+
+describe('rule: undocumented-error-enum', () => {
+ it('flags a #[contracterror] enum without a doc comment', () => {
+ const source = `#[contracterror]\npub enum ContractError {\n NotFound = 1,\n}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'undocumented-error-enum');
+ expect(issue).toBeDefined();
+ expect(issue!.severity).toBe('info');
+ });
+
+ it('does not flag when a doc comment exists above #[contracterror]', () => {
+ const source = `/// Error codes returned by this contract.\n#[contracterror]\npub enum ContractError {\n NotFound = 1,\n}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'undocumented-error-enum');
+ expect(issue).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: event-missing-topics
+// ---------------------------------------------------------------------------
+
+describe('rule: event-missing-topics', () => {
+ it('flags an event published with only one topic', () => {
+ const source = `env.events().publish((Symbol::new(&env, "transfer"),), data);`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'event-missing-topics');
+ expect(issue).toBeDefined();
+ expect(issue!.severity).toBe('info');
+ });
+
+ it('does not flag an event with two topics', () => {
+ const source = `env.events().publish((Symbol::new(&env, "transfer"), from), data);`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'event-missing-topics');
+ expect(issue).toBeUndefined();
+ });
+
+ it('does not flag lines that do not call .publish()', () => {
+ const source = `let events = env.events();`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'event-missing-topics');
+ expect(issue).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: raw-bytes-storage-key
+// ---------------------------------------------------------------------------
+
+describe('rule: raw-bytes-storage-key', () => {
+ it('flags Bytes:: used as a storage key in .set()', () => {
+ const source = `env.storage().instance().set(Bytes::new(&env), &value);`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'raw-bytes-storage-key');
+ expect(issue).toBeDefined();
+ expect(issue!.severity).toBe('info');
+ });
+
+ it('flags BytesN:: used as a storage key in .get()', () => {
+ const source = `env.storage().persistent().get(BytesN::<32>::new(&env));`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'raw-bytes-storage-key');
+ expect(issue).toBeDefined();
+ });
+
+ it('does not flag Symbol keys in storage', () => {
+ const source = `env.storage().instance().set(Symbol::new(&env, "bal"), &100);`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'raw-bytes-storage-key');
+ expect(issue).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: panic-only-error-handling
+// ---------------------------------------------------------------------------
+
+describe('rule: panic-only-error-handling', () => {
+ it('flags panic!() as an error', () => {
+ const source = `fn foo() { panic!("oh no"); }`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'panic-only-error-handling' && i.severity === 'error');
+ expect(issue).toBeDefined();
+ });
+
+ it('flags .unwrap() as a warning', () => {
+ const source = `let x = some_option.unwrap();`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find(
+ (i) => i.rule === 'panic-only-error-handling' && i.severity === 'warning'
+ );
+ expect(issue).toBeDefined();
+ expect(issue!.suggestion).toContain('unwrap_or');
+ });
+
+ it('does not flag panic!() in a comment', () => {
+ const source = `// You can use panic!() but we prefer Result types`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find(
+ (i) => i.rule === 'panic-only-error-handling' && i.severity === 'error'
+ );
+ expect(issue).toBeUndefined();
+ });
+
+ it('does not flag .unwrap() in a comment', () => {
+ const source = `// calling .unwrap() on None will panic`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find(
+ (i) => i.rule === 'panic-only-error-handling' && i.severity === 'warning'
+ );
+ expect(issue).toBeUndefined();
+ });
+
+ it('reports the correct line for panic!', () => {
+ const source = `fn foo() {\n panic!("bad");\n}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find(
+ (i) => i.rule === 'panic-only-error-handling' && i.severity === 'error'
+ );
+ expect(issue?.line).toBe(2);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// RULE: unnamed-fn-params
+// ---------------------------------------------------------------------------
+
+describe('rule: unnamed-fn-params', () => {
+ it('flags a function with an _ parameter', () => {
+ const source = `use soroban_sdk::{contract, contractimpl, Env};
+#[contract]
+pub struct C;
+#[contractimpl]
+impl C {
+ /// Docs here.
+ pub fn transfer(_: Env) {}
+}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'unnamed-fn-params');
+ expect(issue).toBeDefined();
+ expect(issue!.severity).toBe('info');
+ });
+
+ it('does not flag well-named parameters', () => {
+ const source = `use soroban_sdk::{contract, contractimpl, Env};
+#[contract]
+pub struct C;
+#[contractimpl]
+impl C {
+ /// Docs here.
+ pub fn transfer(env: Env, amount: i128) {}
+}`;
+ const result = auditSorobanSource(source);
+ const issue = result.issues.find((i) => i.rule === 'unnamed-fn-params');
+ expect(issue).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Severity counts
+// ---------------------------------------------------------------------------
+
+describe('counts', () => {
+ it('counts errors correctly', () => {
+ const source = `use soroban_sdk::{};\npub struct A;\npub struct B;\n`; // 2 missing #[contract]
+ const result = auditSorobanSource(source);
+ expect(result.counts.error).toBeGreaterThanOrEqual(2);
+ });
+
+ it('counts warnings correctly', () => {
+ const source = `fn f() {\n let x = opt.unwrap();\n let y = opt2.unwrap();\n}`;
+ const result = auditSorobanSource(source);
+ expect(result.counts.warning).toBeGreaterThanOrEqual(2);
+ });
+
+ it('hasIssues is true when there are issues', () => {
+ const source = `use soroban_sdk::{};\npub struct A;`;
+ expect(auditSorobanSource(source).hasIssues).toBe(true);
+ });
+
+ it('hasIssues is false when clean', () => {
+ expect(auditSorobanSource(CLEAN_CONTRACT).hasIssues).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Issue sort order
+// ---------------------------------------------------------------------------
+
+describe('sort order', () => {
+ it('issues are sorted by line number ascending', () => {
+ const source = `fn f() { panic!("a"); }
+use soroban_sdk::{};\npub struct X;`;
+ const result = auditSorobanSource(source);
+ const lines = result.issues.map((i) => i.line);
+ const sorted = [...lines].sort((a, b) => a - b);
+ expect(lines).toEqual(sorted);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// filterIssuesBySeverity
+// ---------------------------------------------------------------------------
+
+describe('filterIssuesBySeverity', () => {
+ it('returns only error issues', () => {
+ const source = `use soroban_sdk::{};\npub struct X;\nfn f() { let x = v.unwrap(); }`;
+ const result = auditSorobanSource(source);
+ const errors = filterIssuesBySeverity(result, 'error');
+ expect(errors.every((i) => i.severity === 'error')).toBe(true);
+ });
+
+ it('returns only warning issues', () => {
+ const source = `fn f() { let x = v.unwrap(); }`;
+ const result = auditSorobanSource(source);
+ const warnings = filterIssuesBySeverity(result, 'warning');
+ expect(warnings.every((i) => i.severity === 'warning')).toBe(true);
+ });
+
+ it('returns empty array when no issues of that severity', () => {
+ expect(filterIssuesBySeverity(auditSorobanSource(CLEAN_CONTRACT), 'error')).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatAuditSummary
+// ---------------------------------------------------------------------------
+
+describe('formatAuditSummary', () => {
+ it('returns a passing message when no issues', () => {
+ const result = auditSorobanSource(CLEAN_CONTRACT);
+ expect(formatAuditSummary(result)).toBe('Accessibility audit passed. No issues found.');
+ });
+
+ it('includes error count in the summary', () => {
+ const source = `use soroban_sdk::{};\npub struct X;`;
+ const result = auditSorobanSource(source);
+ const summary = formatAuditSummary(result);
+ expect(summary).toMatch(/\d+ error/);
+ });
+
+ it('includes warning count in the summary', () => {
+ const source = `fn f() { v.unwrap(); }`;
+ const result = auditSorobanSource(source);
+ const summary = formatAuditSummary(result);
+ expect(summary).toMatch(/\d+ warning/);
+ });
+
+ it('uses singular form for 1 error', () => {
+ const source = `use soroban_sdk::{};\npub struct X;`;
+ const result = auditSorobanSource(source);
+ // Only one struct → exactly 1 error for missing-contract-attribute
+ // Filter to a synthetic result with count 1
+ const single = { ...result, counts: { error: 1, warning: 0, info: 0 } };
+ expect(formatAuditSummary(single)).toContain('1 error');
+ expect(formatAuditSummary(single)).not.toContain('1 errors');
+ });
+
+ it('uses plural form for multiple errors', () => {
+ const source = `use soroban_sdk::{};\npub struct A;\npub struct B;`;
+ const result = auditSorobanSource(source);
+ if (result.counts.error >= 2) {
+ expect(formatAuditSummary(result)).toMatch(/\d+ errors/);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Clean contract produces no issues
+// ---------------------------------------------------------------------------
+
+describe('clean contract baseline', () => {
+ it('the CLEAN_CONTRACT fixture passes the audit', () => {
+ const result = auditSorobanSource(CLEAN_CONTRACT);
+ expect(result.passed).toBe(true);
+ expect(result.issues).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Multiple co-occurring rules
+// ---------------------------------------------------------------------------
+
+describe('multiple rules triggered simultaneously', () => {
+ it('catches missing-contract-attribute and panic! in the same file', () => {
+ const source = `use soroban_sdk::{};\npub struct X;\nfn f() { panic!("bad"); }`;
+ const result = auditSorobanSource(source);
+ const rules = result.issues.map((i) => i.rule);
+ expect(rules).toContain('missing-contract-attribute');
+ expect(rules).toContain('panic-only-error-handling');
+ });
+
+ it('catches opaque error codes and undocumented error enum together', () => {
+ const source = `#[contracterror]\npub enum E {\n E1 = 1,\n}`;
+ const result = auditSorobanSource(source);
+ const rules = result.issues.map((i) => i.rule);
+ expect(rules).toContain('opaque-error-code');
+ expect(rules).toContain('undocumented-error-enum');
+ });
+});
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 5bf23939..aa378734 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -1,5 +1,5 @@
-import { defineConfig } from 'vitest/config';
import path from 'path';
+import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
@@ -22,11 +22,14 @@ export default defineConfig({
},
include: [
'src/lib/keyboard-navigation.ts',
+ 'src/lib/editor/SorobanAccessibilityAuditor.ts',
'src/hooks/useKeyboardNavigation.ts',
'src/hooks/useFocusTrap.ts',
'src/hooks/useRovingTabindex.ts',
+ 'src/hooks/useAccessibilityAudit.ts',
'src/components/ui/SkipLink.tsx',
'src/components/ui/FocusTrap.tsx',
+ 'src/components/playground/AccessibilityAuditPanel.tsx',
],
},
},