@thehoneyjar/sigil-diagnostics 0.1.0

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.
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @sigil/diagnostics
3
+ *
4
+ * Physics compliance checking and issue detection for Sigil.
5
+ */
6
+
7
+ // Types
8
+ export type {
9
+ EffectType,
10
+ Severity,
11
+ PatternCategory,
12
+ DiagnosticIssue,
13
+ BehavioralCompliance,
14
+ AnimationCompliance,
15
+ MaterialCompliance,
16
+ ComplianceResult,
17
+ DiagnosticResult,
18
+ PatternCause,
19
+ DiagnosticPattern,
20
+ PatternMatchResult,
21
+ DiagnosticsConfig,
22
+ DiagnosticsService,
23
+ } from './types'
24
+
25
+ export { DiagnosticsError, DiagnosticsErrorCodes } from './types'
26
+
27
+ // Detection
28
+ export { detectEffect, getExpectedPhysics, keywords } from './detection'
29
+
30
+ // Compliance
31
+ export {
32
+ checkBehavioralCompliance,
33
+ checkAnimationCompliance,
34
+ checkMaterialCompliance,
35
+ checkCompliance,
36
+ complianceToIssues,
37
+ isFullyCompliant,
38
+ } from './compliance'
39
+
40
+ // Patterns
41
+ export { PATTERNS, getPatterns, getPatternsByCategory, getPatternById } from './patterns'
42
+
43
+ // Service
44
+ export {
45
+ createDiagnosticsService,
46
+ getDiagnosticsService,
47
+ resetDiagnosticsService,
48
+ } from './service'
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Known Diagnostic Patterns
3
+ *
4
+ * Patterns for detecting common issues in React/Next.js applications.
5
+ */
6
+
7
+ import type { DiagnosticPattern } from './types'
8
+
9
+ /**
10
+ * Built-in diagnostic patterns
11
+ */
12
+ export const PATTERNS: DiagnosticPattern[] = [
13
+ // Hydration Issues
14
+ {
15
+ id: 'hydration-media-query',
16
+ name: 'useMediaQuery Hydration Mismatch',
17
+ category: 'hydration',
18
+ severity: 'error',
19
+ symptoms: [
20
+ 'Text content does not match server-rendered HTML',
21
+ 'Hydration failed because the initial UI does not match',
22
+ 'Component flickers on load',
23
+ 'Different content on refresh vs navigation',
24
+ ],
25
+ keywords: ['hydration', 'flicker', 'mismatch', 'ssr', 'server'],
26
+ causes: [
27
+ {
28
+ name: 'useMediaQuery SSR mismatch',
29
+ signature: 'useMediaQuery returns false on server, true on client',
30
+ codeSmell: `const isDesktop = useMediaQuery("(min-width: 768px)");
31
+ return isDesktop ? <Dialog /> : <Drawer />;`,
32
+ solution: `// Option 1: Loading state until mounted
33
+ const [mounted, setMounted] = useState(false);
34
+ useEffect(() => setMounted(true), []);
35
+ if (!mounted) return <Skeleton />;
36
+
37
+ // Option 2: CSS-only responsive
38
+ <div className="hidden md:block"><Dialog /></div>
39
+ <div className="md:hidden"><Drawer /></div>`,
40
+ },
41
+ {
42
+ name: 'Date/time in render',
43
+ signature: 'new Date() in render path',
44
+ codeSmell: `return <span>{new Date().toLocaleString()}</span>`,
45
+ solution: `const [time, setTime] = useState<string | null>(null);
46
+ useEffect(() => {
47
+ setTime(new Date().toLocaleString());
48
+ }, []);
49
+ return <span>{time ?? 'Loading...'}</span>;`,
50
+ },
51
+ {
52
+ name: 'Random values in render',
53
+ signature: 'Math.random() or crypto.randomUUID() in render',
54
+ solution: `// Use useId() for stable IDs
55
+ const id = useId();
56
+
57
+ // Or generate once
58
+ const [randomId] = useState(() => crypto.randomUUID());`,
59
+ },
60
+ ],
61
+ },
62
+
63
+ // Dialog Issues
64
+ {
65
+ id: 'dialog-instability',
66
+ name: 'Dialog/Modal Instability',
67
+ category: 'dialog',
68
+ severity: 'error',
69
+ symptoms: [
70
+ "Dialog doesn't open reliably",
71
+ 'Visual glitch during open/close',
72
+ 'Works on desktop, fails on mobile',
73
+ 'Absolute positioned elements misaligned',
74
+ 'Content jumps or shifts',
75
+ ],
76
+ keywords: ['dialog', 'modal', 'drawer', 'glitch', 'popup', 'open', 'close'],
77
+ causes: [
78
+ {
79
+ name: 'ResponsiveDialog hydration',
80
+ signature: 'useMediaQuery controlling Dialog vs Drawer',
81
+ solution: `// Option 1: CSS container queries
82
+ .dialog-content {
83
+ @container (min-width: 768px) {
84
+ /* desktop styles */
85
+ }
86
+ }
87
+
88
+ // Option 2: Consistent loading state
89
+ if (!mounted) return <DialogSkeleton />;`,
90
+ },
91
+ {
92
+ name: 'Absolute positioning context mismatch',
93
+ signature: 'absolute positioning with varying parent chains',
94
+ codeSmell: `<div className="absolute -top-4">Title</div>`,
95
+ solution: `// Ensure explicit positioning context
96
+ <div className="relative">
97
+ <div className="absolute -top-4">Title</div>
98
+ </div>`,
99
+ },
100
+ {
101
+ name: 'CSS overflow conflicts',
102
+ signature: 'overflow-auto on parent, overflow-visible on child',
103
+ solution: `// Be explicit at each level
104
+ // Or restructure to avoid conflict
105
+ // Or use style prop for explicit control`,
106
+ },
107
+ ],
108
+ },
109
+
110
+ // Performance Issues
111
+ {
112
+ id: 'render-performance',
113
+ name: 'Render Performance Issues',
114
+ category: 'performance',
115
+ severity: 'warning',
116
+ symptoms: [
117
+ 'Laggy interactions',
118
+ 'Delayed response to clicks',
119
+ 'Janky animations',
120
+ 'UI feels heavy',
121
+ 'High INP',
122
+ ],
123
+ keywords: ['slow', 'laggy', 'janky', 'performance', 'heavy', 'delay'],
124
+ causes: [
125
+ {
126
+ name: 'Unnecessary re-renders',
127
+ signature: 'Large component tree re-rendering on state change',
128
+ solution: `// Memoize expensive children
129
+ const MemoizedChild = memo(Child);
130
+
131
+ // Colocate state (move it down)
132
+ // Use useMemo for expensive computations
133
+ const processed = useMemo(() => expensiveWork(data), [data]);`,
134
+ },
135
+ {
136
+ name: 'Layout thrashing',
137
+ signature: 'Reading layout, writing, reading again',
138
+ solution: `// Batch reads, then batch writes
139
+ // Use requestAnimationFrame
140
+ // Use CSS transforms instead of top/left`,
141
+ },
142
+ ],
143
+ },
144
+
145
+ // Layout Shift
146
+ {
147
+ id: 'layout-shift',
148
+ name: 'Cumulative Layout Shift (CLS)',
149
+ category: 'layout',
150
+ severity: 'warning',
151
+ symptoms: [
152
+ 'Content jumps after load',
153
+ 'Buttons move as clicking',
154
+ 'High CLS score',
155
+ 'Page is jumpy',
156
+ ],
157
+ keywords: ['jump', 'shift', 'cls', 'move', 'jumpy'],
158
+ causes: [
159
+ {
160
+ name: 'Images without dimensions',
161
+ signature: '<img> without width/height',
162
+ solution: `<Image
163
+ src={src}
164
+ width={400}
165
+ height={300}
166
+ alt="..."
167
+ />
168
+
169
+ // Or use aspect-ratio
170
+ <div className="aspect-video">
171
+ <img className="object-cover" />
172
+ </div>`,
173
+ },
174
+ {
175
+ name: 'Dynamic content without placeholder',
176
+ signature: 'Content loads and pushes things down',
177
+ solution: `// Reserve space
178
+ <div className="min-h-[200px]">
179
+ {loading ? <Skeleton /> : <Content />}
180
+ </div>`,
181
+ },
182
+ ],
183
+ },
184
+
185
+ // Server Components
186
+ {
187
+ id: 'server-component-error',
188
+ name: 'Server Component Errors',
189
+ category: 'server-component',
190
+ severity: 'error',
191
+ symptoms: [
192
+ 'useState is not a function',
193
+ 'useEffect is not a function',
194
+ 'Cannot use hooks in Server Component',
195
+ 'Event handlers cannot be passed',
196
+ ],
197
+ keywords: ['server', 'component', 'hook', 'usestate', 'useeffect', 'client'],
198
+ causes: [
199
+ {
200
+ name: 'Hooks in Server Component',
201
+ signature: 'useState/useEffect without "use client"',
202
+ solution: `// Add at top of file
203
+ 'use client';
204
+
205
+ // Or extract to Client Component`,
206
+ },
207
+ ],
208
+ },
209
+
210
+ // React 19
211
+ {
212
+ id: 'react-19-changes',
213
+ name: 'React 19 Breaking Changes',
214
+ category: 'react-19',
215
+ severity: 'warning',
216
+ symptoms: ['forwardRef is deprecated', 'Unexpected behavior after upgrade'],
217
+ keywords: ['react 19', 'forwardref', 'upgrade', 'deprecated'],
218
+ causes: [
219
+ {
220
+ name: 'forwardRef deprecated',
221
+ signature: 'Using forwardRef pattern',
222
+ codeSmell: `const Button = forwardRef((props, ref) => ...);`,
223
+ solution: `// ref is now a regular prop
224
+ function Button({ ref, ...props }) {
225
+ return <button ref={ref} {...props} />;
226
+ }`,
227
+ },
228
+ ],
229
+ },
230
+
231
+ // Physics Compliance
232
+ {
233
+ id: 'physics-financial-optimistic',
234
+ name: 'Financial Action Using Optimistic Sync',
235
+ category: 'physics',
236
+ severity: 'error',
237
+ symptoms: [
238
+ 'Financial action uses optimistic update',
239
+ 'Money operation without confirmation',
240
+ 'Transaction rolls back after user sees success',
241
+ ],
242
+ keywords: [
243
+ 'claim',
244
+ 'deposit',
245
+ 'withdraw',
246
+ 'transfer',
247
+ 'swap',
248
+ 'optimistic',
249
+ ],
250
+ causes: [
251
+ {
252
+ name: 'Optimistic update on financial mutation',
253
+ signature: 'onMutate used for financial operations',
254
+ codeSmell: `useMutation({
255
+ mutationFn: claimRewards,
256
+ onMutate: async () => {
257
+ // Optimistic update - WRONG for financial!
258
+ queryClient.setQueryData(['balance'], newBalance)
259
+ }
260
+ })`,
261
+ solution: `// Use pessimistic sync for financial operations
262
+ useMutation({
263
+ mutationFn: claimRewards,
264
+ // NO onMutate - wait for server confirmation
265
+ onSuccess: () => {
266
+ queryClient.invalidateQueries(['balance'])
267
+ }
268
+ })`,
269
+ },
270
+ ],
271
+ },
272
+
273
+ {
274
+ id: 'physics-destructive-no-confirm',
275
+ name: 'Destructive Action Without Confirmation',
276
+ category: 'physics',
277
+ severity: 'error',
278
+ symptoms: [
279
+ 'Delete button with no confirmation',
280
+ 'Permanent action happens immediately',
281
+ 'No way to undo destructive operation',
282
+ ],
283
+ keywords: ['delete', 'remove', 'destroy', 'revoke', 'terminate'],
284
+ causes: [
285
+ {
286
+ name: 'Missing confirmation for destructive action',
287
+ signature: 'Destructive action without confirmation step',
288
+ codeSmell: `<button onClick={() => deleteItem()}>Delete</button>`,
289
+ solution: `// Add confirmation step
290
+ const [showConfirm, setShowConfirm] = useState(false);
291
+
292
+ return showConfirm ? (
293
+ <ConfirmDialog
294
+ message="Are you sure you want to delete?"
295
+ onConfirm={() => deleteItem()}
296
+ onCancel={() => setShowConfirm(false)}
297
+ />
298
+ ) : (
299
+ <button onClick={() => setShowConfirm(true)}>Delete</button>
300
+ );`,
301
+ },
302
+ ],
303
+ },
304
+ ]
305
+
306
+ /**
307
+ * Get all patterns
308
+ */
309
+ export function getPatterns(): DiagnosticPattern[] {
310
+ return PATTERNS
311
+ }
312
+
313
+ /**
314
+ * Get patterns by category
315
+ */
316
+ export function getPatternsByCategory(
317
+ category: DiagnosticPattern['category']
318
+ ): DiagnosticPattern[] {
319
+ return PATTERNS.filter((p) => p.category === category)
320
+ }
321
+
322
+ /**
323
+ * Get pattern by ID
324
+ */
325
+ export function getPatternById(id: string): DiagnosticPattern | undefined {
326
+ return PATTERNS.find((p) => p.id === id)
327
+ }
package/src/service.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Diagnostics Service
3
+ *
4
+ * Main service for physics compliance checking and issue detection.
5
+ */
6
+
7
+ import type {
8
+ DiagnosticsService,
9
+ DiagnosticsConfig,
10
+ DiagnosticResult,
11
+ EffectType,
12
+ ComplianceResult,
13
+ PatternMatchResult,
14
+ DiagnosticsError,
15
+ } from './types'
16
+ import { DiagnosticsErrorCodes } from './types'
17
+ import { detectEffect, getExpectedPhysics } from './detection'
18
+ import {
19
+ checkCompliance,
20
+ complianceToIssues,
21
+ isFullyCompliant,
22
+ } from './compliance'
23
+ import { PATTERNS, getPatternById } from './patterns'
24
+
25
+ /**
26
+ * Default compliance result for when physics aren't specified
27
+ */
28
+ function createDefaultCompliance(effect: EffectType): ComplianceResult {
29
+ const behavioral = getExpectedPhysics(effect)
30
+ return {
31
+ behavioral: {
32
+ ...behavioral,
33
+ compliant: true,
34
+ },
35
+ animation: {
36
+ easing: 'ease-out',
37
+ duration: behavioral.timing,
38
+ compliant: true,
39
+ },
40
+ material: {
41
+ surface: 'elevated',
42
+ shadow: 'soft',
43
+ compliant: true,
44
+ },
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Create a diagnostics service
50
+ */
51
+ export function createDiagnosticsService(
52
+ anchorClient?: unknown,
53
+ config: DiagnosticsConfig = {}
54
+ ): DiagnosticsService {
55
+ const patterns = [...PATTERNS, ...(config.customPatterns ?? [])]
56
+
57
+ return {
58
+ async analyze(component: string, code?: string): Promise<DiagnosticResult> {
59
+ // Extract keywords from component name and code
60
+ const keywords = extractKeywords(component, code)
61
+
62
+ // Detect effect type
63
+ const effect = detectEffect(keywords)
64
+
65
+ // Get expected physics for this effect
66
+ const expectedPhysics = getExpectedPhysics(effect)
67
+
68
+ // Check compliance (if code is provided, we could parse it for actual values)
69
+ // For now, we'll use defaults
70
+ const compliance = createDefaultCompliance(effect)
71
+
72
+ // Generate issues from compliance
73
+ const issues = complianceToIssues(compliance)
74
+
75
+ // Match against known patterns if code is provided
76
+ if (code) {
77
+ const patternIssues = matchCodePatterns(code, patterns)
78
+ issues.push(...patternIssues)
79
+ }
80
+
81
+ // Generate suggestions based on effect type
82
+ const suggestions = generateSuggestions(effect, compliance)
83
+
84
+ return {
85
+ component,
86
+ effect,
87
+ issues,
88
+ compliance,
89
+ suggestions,
90
+ }
91
+ },
92
+
93
+ checkCompliance(
94
+ effect: EffectType,
95
+ physics: Partial<ComplianceResult>
96
+ ): boolean {
97
+ const result = checkCompliance(effect, physics)
98
+ return isFullyCompliant(result)
99
+ },
100
+
101
+ detectEffect(keywords: string[], types?: string[]): EffectType {
102
+ return detectEffect(keywords, types)
103
+ },
104
+
105
+ matchPatterns(symptoms: string): PatternMatchResult[] {
106
+ const results: PatternMatchResult[] = []
107
+ const lowerSymptoms = symptoms.toLowerCase()
108
+
109
+ for (const pattern of patterns) {
110
+ // Filter by category if specified
111
+ if (config.categories && !config.categories.includes(pattern.category)) {
112
+ continue
113
+ }
114
+
115
+ // Check keywords
116
+ const keywordMatches = pattern.keywords.filter((k) =>
117
+ lowerSymptoms.includes(k.toLowerCase())
118
+ )
119
+
120
+ if (keywordMatches.length === 0) continue
121
+
122
+ // Check symptoms
123
+ const symptomMatches = pattern.symptoms.filter(
124
+ (s) =>
125
+ lowerSymptoms.includes(s.toLowerCase()) ||
126
+ s
127
+ .toLowerCase()
128
+ .split(' ')
129
+ .some((word) => lowerSymptoms.includes(word))
130
+ )
131
+
132
+ // Calculate confidence
133
+ const confidence = Math.min(
134
+ 0.95,
135
+ keywordMatches.length * 0.2 + symptomMatches.length * 0.3
136
+ )
137
+
138
+ if (confidence > 0.3) {
139
+ // Pick most likely cause
140
+ const matchedCause = pattern.causes[0]
141
+
142
+ results.push({
143
+ pattern,
144
+ matchedCause,
145
+ confidence,
146
+ })
147
+ }
148
+ }
149
+
150
+ // Sort by confidence
151
+ return results.sort((a, b) => b.confidence - a.confidence)
152
+ },
153
+
154
+ diagnose(symptom: string): string {
155
+ const results = this.matchPatterns(symptom)
156
+
157
+ if (results.length === 0) {
158
+ return "I couldn't match this to a known pattern. Can you describe what's happening in more detail?"
159
+ }
160
+
161
+ const top = results[0]
162
+ let response = `**Found: ${top.pattern.name}** (${Math.round(top.confidence * 100)}% confidence)\n\n`
163
+ response += `**Cause:** ${top.matchedCause.name}\n\n`
164
+
165
+ if (top.matchedCause.codeSmell) {
166
+ response += `**Code smell:**\n\`\`\`typescript\n${top.matchedCause.codeSmell}\n\`\`\`\n\n`
167
+ }
168
+
169
+ response += `**Solution:**\n\`\`\`typescript\n${top.matchedCause.solution}\n\`\`\``
170
+
171
+ return response.trim()
172
+ },
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Extract keywords from component name and code
178
+ */
179
+ function extractKeywords(component: string, code?: string): string[] {
180
+ const keywords: string[] = []
181
+
182
+ // Split component name into words
183
+ const componentWords = component
184
+ .replace(/([A-Z])/g, ' $1')
185
+ .toLowerCase()
186
+ .split(/[\s_-]+/)
187
+ .filter(Boolean)
188
+ keywords.push(...componentWords)
189
+
190
+ // Extract keywords from code if provided
191
+ if (code) {
192
+ // Look for common patterns
193
+ const patterns = [
194
+ /onClick\s*=\s*\{.*?(delete|remove|save|submit|claim|withdraw)/gi,
195
+ /mutation[Ff]n:\s*.*?(delete|remove|save|submit|claim|withdraw)/gi,
196
+ /useMutation.*?(delete|remove|save|submit|claim|withdraw)/gi,
197
+ ]
198
+
199
+ for (const pattern of patterns) {
200
+ const matches = code.match(pattern)
201
+ if (matches) {
202
+ keywords.push(...matches)
203
+ }
204
+ }
205
+ }
206
+
207
+ return keywords
208
+ }
209
+
210
+ /**
211
+ * Match code against known patterns
212
+ */
213
+ function matchCodePatterns(
214
+ code: string,
215
+ patterns: typeof PATTERNS
216
+ ): import('./types').DiagnosticIssue[] {
217
+ const issues: import('./types').DiagnosticIssue[] = []
218
+
219
+ // Check for optimistic update on financial operations
220
+ if (
221
+ code.includes('onMutate') &&
222
+ (code.includes('claim') ||
223
+ code.includes('withdraw') ||
224
+ code.includes('transfer'))
225
+ ) {
226
+ issues.push({
227
+ severity: 'error',
228
+ code: 'FINANCIAL_OPTIMISTIC',
229
+ message:
230
+ 'Detected optimistic update (onMutate) on financial operation. Financial operations should use pessimistic sync.',
231
+ suggestion:
232
+ 'Remove onMutate and use onSuccess with query invalidation instead.',
233
+ })
234
+ }
235
+
236
+ // Check for delete without confirmation
237
+ if (
238
+ code.includes('delete') &&
239
+ !code.includes('confirm') &&
240
+ !code.includes('showConfirm')
241
+ ) {
242
+ const hasDirectDelete =
243
+ /onClick\s*=\s*\{.*?delete/i.test(code) ||
244
+ /<button[^>]*>.*?[Dd]elete.*?<\/button>/i.test(code)
245
+
246
+ if (hasDirectDelete) {
247
+ issues.push({
248
+ severity: 'warning',
249
+ code: 'DESTRUCTIVE_NO_CONFIRM',
250
+ message:
251
+ 'Delete operation appears to have no confirmation step. Destructive actions should require confirmation.',
252
+ suggestion:
253
+ 'Add a confirmation dialog before executing the delete operation.',
254
+ })
255
+ }
256
+ }
257
+
258
+ // Check for useMediaQuery without mount check
259
+ if (code.includes('useMediaQuery') && !code.includes('mounted')) {
260
+ issues.push({
261
+ severity: 'warning',
262
+ code: 'HYDRATION_MEDIA_QUERY',
263
+ message:
264
+ 'useMediaQuery without mount check may cause hydration mismatch.',
265
+ suggestion:
266
+ 'Add a mounted state check: const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []);',
267
+ })
268
+ }
269
+
270
+ return issues
271
+ }
272
+
273
+ /**
274
+ * Generate suggestions based on effect type and compliance
275
+ */
276
+ function generateSuggestions(
277
+ effect: EffectType,
278
+ compliance: ComplianceResult
279
+ ): string[] {
280
+ const suggestions: string[] = []
281
+
282
+ if (effect === 'financial') {
283
+ suggestions.push('Use pessimistic sync - no onMutate for financial operations')
284
+ suggestions.push('Show amount and confirmation before executing')
285
+ suggestions.push('Invalidate queries on success to refresh balances')
286
+ }
287
+
288
+ if (effect === 'destructive') {
289
+ suggestions.push('Add two-step confirmation before destructive actions')
290
+ suggestions.push('Use 600ms timing for deliberate feel')
291
+ suggestions.push('Provide clear description of what will be deleted')
292
+ }
293
+
294
+ if (effect === 'soft-delete') {
295
+ suggestions.push('Use toast with undo action instead of confirmation dialog')
296
+ suggestions.push('Optimistic update is safe since operation is reversible')
297
+ }
298
+
299
+ if (!compliance.behavioral.compliant) {
300
+ suggestions.push(
301
+ `Consider changing sync to ${compliance.behavioral.sync} for ${effect} operations`
302
+ )
303
+ }
304
+
305
+ return suggestions
306
+ }
307
+
308
+ /**
309
+ * Default diagnostics service singleton
310
+ */
311
+ let defaultDiagnosticsService: DiagnosticsService | null = null
312
+
313
+ /**
314
+ * Get the default diagnostics service
315
+ */
316
+ export function getDiagnosticsService(
317
+ anchorClient?: unknown
318
+ ): DiagnosticsService {
319
+ if (!defaultDiagnosticsService) {
320
+ defaultDiagnosticsService = createDiagnosticsService(anchorClient)
321
+ }
322
+ return defaultDiagnosticsService
323
+ }
324
+
325
+ /**
326
+ * Reset the default diagnostics service
327
+ */
328
+ export function resetDiagnosticsService(): void {
329
+ defaultDiagnosticsService = null
330
+ }