@@ -294,6 +294,49 @@ function auditFile(file: string, source: string): Violation[] {
294294 return violations
295295}
296296
297+ /** A `persist(` middleware call; `.persist(` (an instance method) is excluded. */
298+ const PERSIST_CALL_PATTERN = / (?< ! [ . \w $ ] ) p e r s i s t \s * (?: < [ ^ ( ) ] * ?> ) ? \s * \( / g
299+
300+ /**
301+ * `.claude/rules/sim-stores.md`: every `persist` names its durable fields in `partialize`.
302+ * Without one, zustand writes the whole state — transient flags, drag state, `_hasHydrated` —
303+ * to storage and rehydrates it on the next load.
304+ */
305+ function auditPersist ( file : string , source : string ) : Violation [ ] {
306+ if ( ! / i m p o r t \s * \{ [ ^ } ] * \b p e r s i s t \b [ ^ } ] * \} \s * f r o m \s * ' z u s t a n d \/ m i d d l e w a r e ' / . test ( source ) ) return [ ]
307+ const violations : Violation [ ] = [ ]
308+ PERSIST_CALL_PATTERN . lastIndex = 0
309+ for (
310+ let match = PERSIST_CALL_PATTERN . exec ( source ) ;
311+ match ;
312+ match = PERSIST_CALL_PATTERN . exec ( source )
313+ ) {
314+ if ( hasSafeAnnotation ( source , match . index ) ) continue
315+ const openParenIndex = match . index + match [ 0 ] . length - 1
316+ const closeParenIndex = findMatchingParen ( source , openParenIndex )
317+ if ( closeParenIndex === - 1 ) continue
318+ const call = source . slice ( openParenIndex + 1 , closeParenIndex )
319+ const hasPartialize = / \b p a r t i a l i z e \b / . test ( call )
320+ const spreadsState = / \b p a r t i a l i z e \s * : \s * \( ? \s * ( \w + ) [ ^ ) ] * \) ? \s * = > \s * \( \s * \{ \s * \. \. \. \1\b / . test (
321+ call
322+ )
323+ if ( hasPartialize && ! spreadsState ) continue
324+ violations . push ( {
325+ file,
326+ line : lineNumberAt ( source , match . index ) ,
327+ description : spreadsState
328+ ? 'persist partialize spreads the whole state; return an explicit whitelist of durable fields'
329+ : 'persist has no partialize; add `partialize: (state) => ({ <durable fields> })` (sim-stores.md)' ,
330+ snippet : oneLineSnippet (
331+ source ,
332+ match . index ,
333+ Math . min ( closeParenIndex + 1 , match . index + 180 )
334+ ) ,
335+ } )
336+ }
337+ return violations
338+ }
339+
297340function returnsPrimitiveDerivedValue ( selector : string ) : boolean {
298341 return (
299342 / \b O b j e c t \. (?: k e y s | v a l u e s | e n t r i e s ) \s * \( [ ^ ) ] * \) \s * \. \s * (?: l e n g t h | s o m e | e v e r y ) \b / . test ( selector ) ||
@@ -351,16 +394,17 @@ async function main() {
351394 const source = await readFile ( file , 'utf8' )
352395 const relativeFile = path . relative ( ROOT , file )
353396 violations . push ( ...auditFile ( relativeFile , source ) )
397+ violations . push ( ...auditPersist ( relativeFile , source ) )
354398 }
355399
356400 if ( violations . length === 0 ) {
357- console . log ( '✅ Zustand v5 selector audit OK' )
401+ console . log ( '✅ Zustand store audit OK (selectors, persist partialize) ' )
358402 return
359403 }
360404
361- console . error ( '❌ Zustand v5 selector hazards found:' )
405+ console . error ( '❌ Zustand store hazards found:' )
362406 console . error (
363- `Add useShallow/useStoreWithEqualityFn, split into primitive selectors , or document intentional exceptions with // ${ SAFE_ANNOTATION } <reason>.`
407+ `Fix each as described , or document an intentional exception with // ${ SAFE_ANNOTATION } <reason>.`
364408 )
365409 for ( const violation of violations ) {
366410 console . error (
0 commit comments