@agentvibes/guardrails 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.
Files changed (260) hide show
  1. package/README.md +239 -0
  2. package/candidates/README.md +30 -0
  3. package/candidates/r10-as-cast.yml +24 -0
  4. package/candidates/r1d-scattered-numeric-const-in-component.yml +13 -0
  5. package/candidates/r2-helper-fn-in-tsx.yml +22 -0
  6. package/candidates/r3-hooks-outside-layout.yml +21 -0
  7. package/candidates/r6-jsx-comment-smell.yml +14 -0
  8. package/candidates/r7-raw-hsl-outside-tokens.yml +17 -0
  9. package/candidates/r8-inline-style-prop.yml +13 -0
  10. package/candidates/r9-optional-store-arg.yml +24 -0
  11. package/configs/biome.json +96 -0
  12. package/configs/tsconfig.base.json +21 -0
  13. package/dist/astGrep.d.ts +16 -0
  14. package/dist/astGrep.js +48 -0
  15. package/dist/astGrep.js.map +1 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +138 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/deploy.d.ts +1 -0
  20. package/dist/deploy.js +34 -0
  21. package/dist/deploy.js.map +1 -0
  22. package/dist/doctor.d.ts +6 -0
  23. package/dist/doctor.js +102 -0
  24. package/dist/doctor.js.map +1 -0
  25. package/dist/fileWalk.d.ts +2 -0
  26. package/dist/fileWalk.js +45 -0
  27. package/dist/fileWalk.js.map +1 -0
  28. package/dist/findings.d.ts +12 -0
  29. package/dist/findings.js +8 -0
  30. package/dist/findings.js.map +1 -0
  31. package/dist/gateRed.d.ts +1 -0
  32. package/dist/gateRed.js +93 -0
  33. package/dist/gateRed.js.map +1 -0
  34. package/dist/gitDiff.d.ts +18 -0
  35. package/dist/gitDiff.js +90 -0
  36. package/dist/gitDiff.js.map +1 -0
  37. package/dist/hookPostedit.d.ts +1 -0
  38. package/dist/hookPostedit.js +117 -0
  39. package/dist/hookPostedit.js.map +1 -0
  40. package/dist/hookStop.d.ts +1 -0
  41. package/dist/hookStop.js +56 -0
  42. package/dist/hookStop.js.map +1 -0
  43. package/dist/hookStopTest.d.ts +1 -0
  44. package/dist/hookStopTest.js +71 -0
  45. package/dist/hookStopTest.js.map +1 -0
  46. package/dist/init.d.ts +24 -0
  47. package/dist/init.js +172 -0
  48. package/dist/init.js.map +1 -0
  49. package/dist/initTest.d.ts +1 -0
  50. package/dist/initTest.js +88 -0
  51. package/dist/initTest.js.map +1 -0
  52. package/dist/iterate.d.ts +9 -0
  53. package/dist/iterate.js +79 -0
  54. package/dist/iterate.js.map +1 -0
  55. package/dist/iterateTest.d.ts +1 -0
  56. package/dist/iterateTest.js +91 -0
  57. package/dist/iterateTest.js.map +1 -0
  58. package/dist/leakPatterns.d.ts +5 -0
  59. package/dist/leakPatterns.js +22 -0
  60. package/dist/leakPatterns.js.map +1 -0
  61. package/dist/leaks.d.ts +8 -0
  62. package/dist/leaks.js +211 -0
  63. package/dist/leaks.js.map +1 -0
  64. package/dist/leaksRed.d.ts +1 -0
  65. package/dist/leaksRed.js +62 -0
  66. package/dist/leaksRed.js.map +1 -0
  67. package/dist/metrics.d.ts +10 -0
  68. package/dist/metrics.js +113 -0
  69. package/dist/metrics.js.map +1 -0
  70. package/dist/metricsAggregate.d.ts +5 -0
  71. package/dist/metricsAggregate.js +78 -0
  72. package/dist/metricsAggregate.js.map +1 -0
  73. package/dist/metricsBaseline.d.ts +21 -0
  74. package/dist/metricsBaseline.js +88 -0
  75. package/dist/metricsBaseline.js.map +1 -0
  76. package/dist/metricsCollect.d.ts +8 -0
  77. package/dist/metricsCollect.js +295 -0
  78. package/dist/metricsCollect.js.map +1 -0
  79. package/dist/metricsCycle.d.ts +1 -0
  80. package/dist/metricsCycle.js +79 -0
  81. package/dist/metricsCycle.js.map +1 -0
  82. package/dist/metricsTypes.d.ts +102 -0
  83. package/dist/metricsTypes.js +2 -0
  84. package/dist/metricsTypes.js.map +1 -0
  85. package/dist/packagePaths.d.ts +7 -0
  86. package/dist/packagePaths.js +11 -0
  87. package/dist/packagePaths.js.map +1 -0
  88. package/dist/pluginApi.d.ts +24 -0
  89. package/dist/pluginApi.js +12 -0
  90. package/dist/pluginApi.js.map +1 -0
  91. package/dist/pluginResolve.d.ts +6 -0
  92. package/dist/pluginResolve.js +48 -0
  93. package/dist/pluginResolve.js.map +1 -0
  94. package/dist/screenScope.d.ts +21 -0
  95. package/dist/screenScope.js +81 -0
  96. package/dist/screenScope.js.map +1 -0
  97. package/dist/screenScopeTest.d.ts +1 -0
  98. package/dist/screenScopeTest.js +116 -0
  99. package/dist/screenScopeTest.js.map +1 -0
  100. package/dist/severity.d.ts +9 -0
  101. package/dist/severity.js +62 -0
  102. package/dist/severity.js.map +1 -0
  103. package/dist/severityTest.d.ts +1 -0
  104. package/dist/severityTest.js +63 -0
  105. package/dist/severityTest.js.map +1 -0
  106. package/dist/sourceLines.d.ts +9 -0
  107. package/dist/sourceLines.js +52 -0
  108. package/dist/sourceLines.js.map +1 -0
  109. package/dist/structure.d.ts +2 -0
  110. package/dist/structure.js +98 -0
  111. package/dist/structure.js.map +1 -0
  112. package/dist/testRules.d.ts +1 -0
  113. package/dist/testRules.js +285 -0
  114. package/dist/testRules.js.map +1 -0
  115. package/dist/textGrep.d.ts +2 -0
  116. package/dist/textGrep.js +52 -0
  117. package/dist/textGrep.js.map +1 -0
  118. package/dist/tomlTable.d.ts +2 -0
  119. package/dist/tomlTable.js +26 -0
  120. package/dist/tomlTable.js.map +1 -0
  121. package/dist/toolResolve.d.ts +18 -0
  122. package/dist/toolResolve.js +50 -0
  123. package/dist/toolResolve.js.map +1 -0
  124. package/dist/verify.d.ts +3 -0
  125. package/dist/verify.js +49 -0
  126. package/dist/verify.js.map +1 -0
  127. package/dist/verifyDiff.d.ts +18 -0
  128. package/dist/verifyDiff.js +126 -0
  129. package/dist/verifyDiff.js.map +1 -0
  130. package/dist/workspaces.d.ts +6 -0
  131. package/dist/workspaces.js +84 -0
  132. package/dist/workspaces.js.map +1 -0
  133. package/mise.toml +9 -0
  134. package/package.json +62 -0
  135. package/rules/__fixtures__/as-any-escape/bad.ts +8 -0
  136. package/rules/__fixtures__/as-any-escape/good.ts +14 -0
  137. package/rules/__fixtures__/catch-empty/bad.ts +7 -0
  138. package/rules/__fixtures__/catch-empty/good.ts +14 -0
  139. package/rules/__fixtures__/direct-store-import/bad.tsx +23 -0
  140. package/rules/__fixtures__/direct-store-import/good.tsx +34 -0
  141. package/rules/__fixtures__/direct-store-import/showcase/ignored.tsx +9 -0
  142. package/rules/__fixtures__/discriminator-ternary/bad.tsx +10 -0
  143. package/rules/__fixtures__/discriminator-ternary/good.tsx +23 -0
  144. package/rules/__fixtures__/hardcoded-url-in-component/components/bad.tsx +8 -0
  145. package/rules/__fixtures__/hardcoded-url-in-component/components/good.tsx +14 -0
  146. package/rules/__fixtures__/inline-map-row/bad.tsx +72 -0
  147. package/rules/__fixtures__/inline-map-row/good.tsx +51 -0
  148. package/rules/__fixtures__/instanceof-map-set/bad.ts +7 -0
  149. package/rules/__fixtures__/instanceof-map-set/good.ts +8 -0
  150. package/rules/__fixtures__/literal-union-in-component/components/bad.ts +8 -0
  151. package/rules/__fixtures__/literal-union-in-component/components/good.ts +23 -0
  152. package/rules/__fixtures__/missing-observer/bad.tsx +66 -0
  153. package/rules/__fixtures__/missing-observer/good.tsx +69 -0
  154. package/rules/__fixtures__/no-local-kit-clone/bad.ts +30 -0
  155. package/rules/__fixtures__/no-local-kit-clone/bad.tsx +30 -0
  156. package/rules/__fixtures__/no-local-kit-clone/good.ts +31 -0
  157. package/rules/__fixtures__/no-local-kit-clone/good.tsx +31 -0
  158. package/rules/__fixtures__/non-exhaustive-match/bad.ts +8 -0
  159. package/rules/__fixtures__/non-exhaustive-match/good.ts +14 -0
  160. package/rules/__fixtures__/page-store-bare-usememo/bad.ts +15 -0
  161. package/rules/__fixtures__/page-store-bare-usememo/bad.tsx +13 -0
  162. package/rules/__fixtures__/page-store-bare-usememo/good.ts +17 -0
  163. package/rules/__fixtures__/page-store-bare-usememo/good.tsx +14 -0
  164. package/rules/__fixtures__/screen-file-styling/bad.screen.tsx +10 -0
  165. package/rules/__fixtures__/screen-file-styling/good.screen.tsx +8 -0
  166. package/rules/__fixtures__/state-loading-boolean-shape/bad.ts +36 -0
  167. package/rules/__fixtures__/state-loading-boolean-shape/bad.tsx +36 -0
  168. package/rules/__fixtures__/state-loading-boolean-shape/good.ts +44 -0
  169. package/rules/__fixtures__/state-loading-boolean-shape/good.tsx +44 -0
  170. package/rules/__fixtures__/store-async-method/bad.ts +37 -0
  171. package/rules/__fixtures__/store-async-method/bad.tsx +37 -0
  172. package/rules/__fixtures__/store-async-method/good.ts +73 -0
  173. package/rules/__fixtures__/store-async-method/good.tsx +73 -0
  174. package/rules/__fixtures__/store-context-provider/bad.ts +16 -0
  175. package/rules/__fixtures__/store-context-provider/bad.tsx +24 -0
  176. package/rules/__fixtures__/store-context-provider/good.ts +13 -0
  177. package/rules/__fixtures__/store-context-provider/good.tsx +11 -0
  178. package/rules/__fixtures__/store-delegation-getter/bad.ts +30 -0
  179. package/rules/__fixtures__/store-delegation-getter/bad.tsx +30 -0
  180. package/rules/__fixtures__/store-delegation-getter/good.ts +65 -0
  181. package/rules/__fixtures__/store-delegation-getter/good.tsx +65 -0
  182. package/rules/__fixtures__/store-new-map/bad.ts +22 -0
  183. package/rules/__fixtures__/store-new-map/bad.tsx +22 -0
  184. package/rules/__fixtures__/store-new-map/good.ts +41 -0
  185. package/rules/__fixtures__/store-new-map/good.tsx +41 -0
  186. package/rules/__fixtures__/store-no-runinaction/bad.ts +67 -0
  187. package/rules/__fixtures__/store-no-runinaction/bad.tsx +67 -0
  188. package/rules/__fixtures__/store-no-runinaction/good.ts +39 -0
  189. package/rules/__fixtures__/store-no-runinaction/good.tsx +39 -0
  190. package/rules/__fixtures__/store-no-setinterval-poll/bad.ts +19 -0
  191. package/rules/__fixtures__/store-no-setinterval-poll/bad.tsx +19 -0
  192. package/rules/__fixtures__/store-no-setinterval-poll/good.ts +29 -0
  193. package/rules/__fixtures__/store-no-setinterval-poll/good.tsx +29 -0
  194. package/rules/__fixtures__/ts-pattern-dangling-match/bad.ts +20 -0
  195. package/rules/__fixtures__/ts-pattern-dangling-match/good.ts +35 -0
  196. package/rules/__fixtures__/ui-imports-app-store/src/ui/bad.tsx +13 -0
  197. package/rules/__fixtures__/ui-imports-app-store/src/ui/good.tsx +23 -0
  198. package/rules/__fixtures__/view-file-logic/bad.view.tsx +16 -0
  199. package/rules/__fixtures__/view-file-logic/good.view.tsx +7 -0
  200. package/rules/__fixtures__/view-imports-store/bad.view.tsx +13 -0
  201. package/rules/__fixtures__/view-imports-store/good.view.tsx +23 -0
  202. package/rules/__fixtures__/zod-optional-nullable/bad.ts +10 -0
  203. package/rules/__fixtures__/zod-optional-nullable/bad.tsx +17 -0
  204. package/rules/__fixtures__/zod-optional-nullable/good.ts +16 -0
  205. package/rules/__fixtures__/zod-optional-nullable/good.tsx +20 -0
  206. package/rules/as-any-escape-tsx.yml +53 -0
  207. package/rules/as-any-escape.yml +29 -0
  208. package/rules/catch-empty-tsx.yml +55 -0
  209. package/rules/catch-empty.yml +31 -0
  210. package/rules/classname-not-composed.yml +28 -0
  211. package/rules/classname-visual-identity.yml +34 -0
  212. package/rules/demo-mode-by-default-ts.yml +76 -0
  213. package/rules/demo-mode-by-default.yml +54 -0
  214. package/rules/direct-store-import.yml +118 -0
  215. package/rules/discriminator-ternary.yml +70 -0
  216. package/rules/hardcoded-url-in-component.yml +44 -0
  217. package/rules/inline-map-row.yml +107 -0
  218. package/rules/instanceof-map-set-tsx.yml +46 -0
  219. package/rules/instanceof-map-set.yml +22 -0
  220. package/rules/json-roundtrip.yml +9 -0
  221. package/rules/jsx-cond-and.yml +16 -0
  222. package/rules/jsx-ternary.yml +35 -0
  223. package/rules/kind-if-without-match.yml +13 -0
  224. package/rules/literal-union-in-component.yml +71 -0
  225. package/rules/match-bool-to-null.yml +15 -0
  226. package/rules/missing-observer.yml +189 -0
  227. package/rules/mobx-effect-observable-dep.yml +23 -0
  228. package/rules/mobx-effect-store-write.yml +21 -0
  229. package/rules/mobx-usestate-from-store.yml +20 -0
  230. package/rules/no-local-kit-clone-tsx.yml +104 -0
  231. package/rules/no-local-kit-clone.yml +93 -0
  232. package/rules/non-exhaustive-match.yml +34 -0
  233. package/rules/page-store-bare-usememo-tsx.yml +47 -0
  234. package/rules/page-store-bare-usememo.yml +43 -0
  235. package/rules/screen-file-styling.yml +29 -0
  236. package/rules/silent-default-return.yml +17 -0
  237. package/rules/state-loading-boolean-shape-tsx.yml +102 -0
  238. package/rules/state-loading-boolean-shape.yml +91 -0
  239. package/rules/store-async-method-tsx.yml +108 -0
  240. package/rules/store-async-method.yml +98 -0
  241. package/rules/store-context-provider-tsx.yml +68 -0
  242. package/rules/store-context-provider.yml +78 -0
  243. package/rules/store-delegation-getter-tsx.yml +104 -0
  244. package/rules/store-delegation-getter.yml +93 -0
  245. package/rules/store-new-map-tsx.yml +84 -0
  246. package/rules/store-new-map.yml +73 -0
  247. package/rules/store-no-runinaction-tsx.yml +71 -0
  248. package/rules/store-no-runinaction.yml +60 -0
  249. package/rules/store-no-setinterval-poll-tsx.yml +62 -0
  250. package/rules/store-no-setinterval-poll.yml +51 -0
  251. package/rules/ts-pattern-dangling-match-tsx.yml +41 -0
  252. package/rules/ts-pattern-dangling-match.yml +63 -0
  253. package/rules/ui-imports-app-store.yml +49 -0
  254. package/rules/view-file-logic.yml +53 -0
  255. package/rules/view-imports-store.yml +64 -0
  256. package/rules/zod-optional-nullable-tsx.yml +56 -0
  257. package/rules/zod-optional-nullable.yml +29 -0
  258. package/sgconfig.yml +8 -0
  259. package/structure/rules/component-decl.yml +48 -0
  260. package/structure/sgconfig.yml +7 -0
@@ -0,0 +1,107 @@
1
+ # ── G1, rule 2 of 3: per-row observer extraction ───────────────────────
2
+ #
3
+ # The MobX list rule from CLAUDE.md: mapping an observable collection with the
4
+ # row body written inline makes the PARENT track every field every row reads, so
5
+ # one item's change invalidates the whole list. Extracting the row into its own
6
+ # `observer` component localises the invalidation.
7
+ #
8
+ # The report's audit found this rule already obeyed everywhere it looked (131
9
+ # `*Row.tsx` files in merkle alone) — so it lands as a ratchet on new code, not
10
+ # as a cleanup campaign.
11
+ #
12
+ # Threshold: the mapped element must contain TWO OR MORE nested JSX elements
13
+ # before this fires. That is what separates a row body from a wrapper, and it is
14
+ # why these stay silent by design:
15
+ #
16
+ # {xs.map(x => <li key={x.id}><Row item={x}/></li>)} one nested element
17
+ # {xs.map(x => <li key={x.id}><a href={x.h}>{x.t}</a></li>)} one nested element
18
+ #
19
+ # Both arguably want extracting too, but this rule is `error`, and an error that
20
+ # fires on a one-element wrapper is an error people suppress. Under-match.
21
+ #
22
+ # Also out of scope by design: a `.map` outside JSX (`const rows = xs.map(…)`
23
+ # assigned first, rendered as `{rows}`). Same defect, no JSX context to key on.
24
+ id: inline-map-row
25
+ language: tsx
26
+ severity: error
27
+ ignores:
28
+ - '**/*.test.tsx'
29
+ - '**/*.spec.tsx'
30
+ - '**/__tests__/**'
31
+ # A gallery/preview harness IS the markup it shows; extracting rows out of it
32
+ # would defeat the point of reading it as one page.
33
+ - '**/showcase/**'
34
+ - '**/*.stories.tsx'
35
+ utils:
36
+ jsx-el:
37
+ any:
38
+ - kind: jsx_element
39
+ - kind: jsx_self_closing_element
40
+ message: |
41
+ An inline row body inside `.map(...)`. The parent is an `observer`, so every
42
+ field this body reads is tracked by the PARENT — one item changing a single
43
+ field re-renders the entire list.
44
+ Extract the body into its own `observer` component and pass the item down:
45
+ {items.map(t => <TaskRow key={t.id} task={t} />)}
46
+ The parent then re-renders only on insert, remove and reorder; each row
47
+ re-renders on its own fields. See CLAUDE.md "MobX lists" and the
48
+ mobx-components skill.
49
+ Doesn't apply when the mapped children are pure data — constants or plain
50
+ props rather than observables.
51
+ rule:
52
+ all:
53
+ - kind: jsx_element
54
+ # Anchor the finding on the row ROOT — the element the callback returns —
55
+ # so a deep body reports once at the line you would edit, instead of once
56
+ # per offending element on the way down. Nested JSX elements are direct
57
+ # children of their parent element, so an unqualified `inside` is exactly
58
+ # the "am I the outermost one" test.
59
+ - not:
60
+ inside:
61
+ kind: jsx_element
62
+ # Two or more nested JSX elements: one that has a JSX sibling before it.
63
+ # `stopBy: end` on `follows` is required — tree-sitter puts `jsx_text`
64
+ # nodes between JSX siblings, so the default immediate-sibling check never
65
+ # sees the element next door.
66
+ - has:
67
+ stopBy: end
68
+ all:
69
+ - matches: jsx-el
70
+ - follows:
71
+ stopBy: end
72
+ matches: jsx-el
73
+ # The enclosing component must BE an observer. This is not a narrowing of
74
+ # convenience — it is the rule's own rationale made into its scope: the
75
+ # defect is that the PARENT'S reactive scope tracks every field every row
76
+ # reads, and a component with no reactive scope has no such defect. It is
77
+ # also the only syntactically decidable form of CLAUDE.md's carve-out
78
+ # ("when the rendered children are pure data … the rule doesn't apply").
79
+ #
80
+ # Measured, not assumed: without this clause the rule fired 50 times in
81
+ # byoklab/components — a UI library and its examples/docs site, which
82
+ # contain no MobX whatsoever — where every hit was a false positive at
83
+ # `error` severity. With it, 0 there.
84
+ #
85
+ # A component that reads observables WITHOUT the wrap is a different defect
86
+ # and belongs to `missing-observer`, not here.
87
+ - inside:
88
+ stopBy: end
89
+ pattern: observer($$$)
90
+ - inside:
91
+ stopBy: end
92
+ all:
93
+ - pattern: $ARR.map($$$CB)
94
+ - inside:
95
+ stopBy: end
96
+ kind: jsx_expression
97
+ # CLAUDE.md's own carve-out: "When the rendered children are pure data
98
+ # (constants, plain props, not MobX observables), the rule doesn't
99
+ # apply." A literal array is the one case that is provably constant —
100
+ # observatory's SessionInfoSkeleton maps `[1, 2, 3, 4]` to draw four
101
+ # placeholder cards, which no amount of extraction improves.
102
+ - not:
103
+ has:
104
+ field: function
105
+ has:
106
+ field: object
107
+ kind: array
@@ -0,0 +1,46 @@
1
+ # ── rule body adopted from faceless-photo-lib `no-instanceof-map-set` (wave 2 triage) ──
2
+ # The two rules were duplicates; the faceless implementation is strictly better,
3
+ # so the canon id keeps its message and takes that body. Why it is better here:
4
+ # the old patterns named only Map and Set; WeakMap and WeakSet have exactly the
5
+ # same cross-realm failure and were invisible.
6
+ #
7
+ # The .tsx half of `instanceof-map-set`.
8
+ #
9
+ # ast-grep's `typescript` and `tsx` languages are DISJOINT, not nested: a rule
10
+ # at `language: typescript` matches `.ts` and NEVER `.tsx`, so `instanceof-map-set`
11
+ # was blind to every React component file. Verified by effect — byte-identical
12
+ # content in `p.ts` and `p.tsx`, the first flagged and the second silent.
13
+ #
14
+ # Three ways to cover both were tried and two were rejected on measurement:
15
+ # - `language: [typescript, tsx]` — ast-grep refuses to parse the rule.
16
+ # - `languageGlobs: {tsx: ["*.ts"]}` — reassigns `.ts` to the tsx language, so
17
+ # EVERY remaining `language: typescript` rule goes dark. Measured corpus-wide:
18
+ # 5 rules blinded, 6,094 findings lost against 334 gained. It would have
19
+ # looked like a coverage win.
20
+ # - two files with the SAME id — "Error: Duplicate rule id".
21
+ # So: a separate id per language. The trailing `-tsx` is what you suppress with
22
+ # in a .tsx file, and the finding prints the id, so copying from the output is
23
+ # always right.
24
+ #
25
+ # Keep this file's `rule:` block IDENTICAL to `instanceof-map-set.yml`. They are one
26
+ # rule wearing two ids because the tool has no way to say "both languages".
27
+ id: instanceof-map-set-tsx
28
+ language: tsx
29
+ severity: error
30
+ message: |
31
+ Do NOT use `instanceof Map / Set`. Data crossing serialization/immer/MobX boundaries
32
+ loses its runtime type. Duck-type capabilities instead, or use a typed accessor.
33
+ See defensive-errors Rule 3 and Rule 4b.
34
+ Suppressing this one needs the id you see above — `ast-grep-ignore: instanceof-map-set-tsx`.
35
+ `instanceof-map-set` is a DIFFERENT rule (the .ts arm) and does not cover it; ast-grep
36
+ matches suppression ids exactly. A bare `// ast-grep-ignore` suppresses every
37
+ rule on the line, and a wrong id is reported back as `unused-suppression`.
38
+ rule:
39
+ kind: binary_expression
40
+ all:
41
+ - has:
42
+ field: operator
43
+ regex: "^instanceof$"
44
+ - has:
45
+ field: right
46
+ regex: "^(Map|Set|WeakMap|WeakSet)$"
@@ -0,0 +1,22 @@
1
+ # ── rule body adopted from faceless-photo-lib `no-instanceof-map-set` (wave 2 triage) ──
2
+ # The two rules were duplicates; the faceless implementation is strictly better,
3
+ # so the canon id keeps its message and takes that body. Why it is better here:
4
+ # the old patterns named only Map and Set; WeakMap and WeakSet have exactly the
5
+ # same cross-realm failure and were invisible.
6
+ #
7
+ id: instanceof-map-set
8
+ language: typescript
9
+ severity: error
10
+ message: |
11
+ Do NOT use `instanceof Map / Set`. Data crossing serialization/immer/MobX boundaries
12
+ loses its runtime type. Duck-type capabilities instead, or use a typed accessor.
13
+ See defensive-errors Rule 3 and Rule 4b.
14
+ rule:
15
+ kind: binary_expression
16
+ all:
17
+ - has:
18
+ field: operator
19
+ regex: "^instanceof$"
20
+ - has:
21
+ field: right
22
+ regex: "^(Map|Set|WeakMap|WeakSet)$"
@@ -0,0 +1,9 @@
1
+ id: json-roundtrip
2
+ language: typescript
3
+ severity: warning
4
+ message: |
5
+ JSON.parse(JSON.stringify(x)) loses Maps, Sets, TypedArrays, class instances, circular refs.
6
+ Use a typed serializer with Zod validation at the boundary.
7
+ See defensive-errors Rule 5.
8
+ rule:
9
+ pattern: "JSON.parse(JSON.stringify($X))"
@@ -0,0 +1,16 @@
1
+ id: jsx-cond-and
2
+ language: tsx
3
+ severity: warning
4
+ message: |
5
+ `{cond && <JSX/>}` in JSX silently drops the !cond branch (renders falsy).
6
+ Use the `<ShowWhen cond={cond}>{() => <JSX/>}</ShowWhen>` helper — it's an
7
+ observer (MobX-reactive) and communicates intent "show this subtree when cond".
8
+ For branches where both arms render something non-null, use match().exhaustive().
9
+ See defensive-errors Rule 1 & 2.
10
+ rule:
11
+ any:
12
+ # Self-closing: {cond && <Foo />}
13
+ - pattern: $COND && <$TAG />
14
+ # With children: {cond && <Foo>…</Foo>}
15
+ - pattern: $COND && <$TAG>$$$CHILDREN</$TAG>
16
+ - pattern: $COND && <$TAG $$$PROPS>$$$CHILDREN</$TAG>
@@ -0,0 +1,35 @@
1
+ id: jsx-ternary
2
+ language: tsx
3
+ severity: warning
4
+ utils:
5
+ # A JSX subtree, however many redundant parens wrap it.
6
+ is-jsx:
7
+ any:
8
+ - kind: jsx_element
9
+ - kind: jsx_self_closing_element
10
+ - all:
11
+ - kind: parenthesized_expression
12
+ - has:
13
+ matches: is-jsx
14
+ message: |
15
+ `cond ? <A/> : <B/>` branches on state with a ternary — the other half of the
16
+ ban `jsx-cond-and` enforces (react.md: "NEVER branch on state with
17
+ `if`/`switch`/ternary or `{cond && <JSX/>}`").
18
+ Both arms render, so nothing leaks into the DOM — what it costs is the build
19
+ break: widen what is being branched on and the second arm silently absorbs
20
+ the new case.
21
+ cond ? <A/> : <B/> → match(value).with(…, () => <A/>).otherwise(() => <B/>)
22
+ or a two-arm .exhaustive() when the union is closed
23
+ xs.length === 0 ? … → match(xs).with([], () => <Empty/>).otherwise(…)
24
+ cond ? <A/> : null → <ShowWhen cond={cond}>{() => <A/>}</ShowWhen>
25
+ Match the value, not a boolean derived from it — that is what makes adding a
26
+ variant a type error. See defensive-errors Rule 1 & 2.
27
+ rule:
28
+ kind: ternary_expression
29
+ any:
30
+ - has:
31
+ field: consequence
32
+ matches: is-jsx
33
+ - has:
34
+ field: alternative
35
+ matches: is-jsx
@@ -0,0 +1,13 @@
1
+ id: kind-if-without-match
2
+ language: typescript
3
+ severity: warning
4
+ message: |
5
+ Discriminated-union branching with if/else silently ignores new variants.
6
+ Use match($X).with({kind: ...}, ...).exhaustive() from ts-pattern.
7
+ See defensive-errors Rule 2 and the pattern-matching skill.
8
+ rule:
9
+ any:
10
+ - pattern: "if ($X.kind === $_) $_"
11
+ - pattern: "if ($X.type === $_) $_"
12
+ - pattern: "if ($X.status === $_) $_"
13
+ - pattern: "if ($X._obj === $_) $_"
@@ -0,0 +1,71 @@
1
+ # PROMOTED to canon in wave 2 from PPA `r14-literal-union-in-component` (rule-triage).
2
+ #
3
+ # Generic, not PPA-specific: the house store layout already states it — report §8
4
+ # puts «string-literal union'ы вроде `type Tab = "tasks" | "habits"`, которым
5
+ # запрещено жить в файлах компонентов» in the `ui/` tier of the four-level model.
6
+ # The PPA original hard-coded `apps/ppa/components/**` and `apps/ppa/app/**`;
7
+ # the globs below are the same intent stated for any repo.
8
+ #
9
+ # 23 hits across tg-gallery + observatory with the PPA globs stripped, so it is
10
+ # a live rule elsewhere and not a local house style.
11
+ id: literal-union-in-component
12
+ language: typescript
13
+ severity: warning
14
+ files:
15
+ - '**/components/**/*.ts'
16
+ - '**/components/**/*.tsx'
17
+ - '**/screens/**/*.ts'
18
+ - '**/screens/**/*.tsx'
19
+ - '**/app/**/*.ts'
20
+ - '**/app/**/*.tsx'
21
+ ignores:
22
+ - '**/__tests__/**'
23
+ - '**/*.test.ts'
24
+ - '**/*.test.tsx'
25
+ - '**/_fixture.ts'
26
+ - '**/_fixtures.ts'
27
+ message: |
28
+ A string-literal union declared inside a component file. Every component that
29
+ touches the same concept redeclares it, and the copies drift — one file grows
30
+ a `"archived"` member the others never hear about, and nothing fails to build
31
+ because each copy is internally consistent.
32
+ Move it to where the state it describes lives:
33
+ - a UI-tier store, next to the actions that mutate it, when it IS state
34
+ (`type Tab = "tasks" | "habits"` belongs to the store that owns the tab), or
35
+ - a shared module as a typed constant + alias when it is purely lexical
36
+ (file kinds, palette names) with no state attached.
37
+ Component prop types then consume the shared type instead of restating it.
38
+ See the mobx-models skill and report §8.
39
+ rule:
40
+ all:
41
+ - kind: type_alias_declaration
42
+ # Two or more STRING members on the union's own spine. The spine matters:
43
+ # tree-sitter nests unions left-associatively, so a 3-member union is
44
+ # `union_type(union_type(lit, lit), lit)` and a 2-member one is
45
+ # `union_type(lit, lit)` — hence the two arms. Searching with `stopBy: end`
46
+ # instead would reach literals nested INSIDE object members and flag
47
+ # `{ status: "idle" } | { status: "ready" }`, the discriminated union this
48
+ # codebase wants everywhere.
49
+ - has:
50
+ field: value
51
+ kind: union_type
52
+ any:
53
+ # three or more members
54
+ - has:
55
+ kind: union_type
56
+ has:
57
+ kind: literal_type
58
+ has:
59
+ kind: string
60
+ # exactly two members
61
+ - has:
62
+ all:
63
+ - kind: literal_type
64
+ - has:
65
+ kind: string
66
+ - follows:
67
+ stopBy: end
68
+ all:
69
+ - kind: literal_type
70
+ - has:
71
+ kind: string
@@ -0,0 +1,15 @@
1
+ id: match-bool-to-null
2
+ language: tsx
3
+ severity: warning
4
+ message: |
5
+ `match(cond).with(true, ...).with(false, () => null).exhaustive()` is the
6
+ ceremonial form of "maybe render this subtree". Use the purpose-built helper:
7
+ <ShowWhen cond={cond}>{() => <JSX/>}</ShowWhen>
8
+ ShowWhen is an observer (MobX-reactive), lazily evaluates children, and
9
+ communicates intent. If BOTH arms render something non-null, keep match().
10
+ rule:
11
+ pattern: |
12
+ match($COND)
13
+ .with(true, () => $$$A)
14
+ .with(false, () => null)
15
+ .exhaustive()
@@ -0,0 +1,189 @@
1
+ # ── G1, rule 1 of 3: the observer wrap ─────────────────────────────────
2
+ #
3
+ # `missing-observer` has been listed under "Validation" in mobx-components/SKILL.md
4
+ # for months while existing in no rules/ directory at all. The sketch that was
5
+ # published alongside it (ast-grep/SKILL.md) could not have matched the canonical
6
+ # code even if it had been installed: it required the literal body
7
+ #
8
+ # export const $NAME = () => { $$$ useStore() $$$ }
9
+ #
10
+ # — a bare `useStore()` statement. Every real call site destructures
11
+ # (`const { data, ui } = useStore()`), so the pattern matched nothing anywhere in
12
+ # the park. This is the rule written against the shapes that are actually there.
13
+ #
14
+ # Deliberately UNDER-matching, and at `warning` rather than `error`. "Reads
15
+ # observable data" is not a syntactic property — a component can hold an
16
+ # observable that arrived through any prop under any name. Guessing wider buys
17
+ # false positives, and a rule that cries wolf is a rule people turn off. Two
18
+ # arms only, both of which name the store at the call site:
19
+ #
20
+ # 1. it calls a `use…Store` hook, or
21
+ # 2. it takes a prop literally named `model` or `store` AND dereferences it.
22
+ #
23
+ # What it therefore MISSES, on purpose: a component handed an observable under
24
+ # any other prop name (`session={s}`, `photo={p}`). Those still need the wrap;
25
+ # this rule is not the thing that will tell you.
26
+ #
27
+ # Known false-positive class, accepted at warning tier: a `use…Store` hook can
28
+ # return non-observable services next to the stores — `const { platform } =
29
+ # useStore()` in tg-gallery's ShareButton pulls a platform adapter, and a
30
+ # component reading only that does not strictly need the wrap. Wrapping it is
31
+ # harmless; the rule does not distinguish.
32
+ id: missing-observer
33
+ language: tsx
34
+ severity: warning
35
+ ignores:
36
+ - '**/*.test.tsx'
37
+ - '**/*.spec.tsx'
38
+ - '**/__tests__/**'
39
+ - '**/showcase/**'
40
+ - '**/*.stories.tsx'
41
+ utils:
42
+ # A fragment (`<>…</>`) parses as a `jsx_element` with empty tags in this
43
+ # grammar, so these two kinds cover every JSX return shape.
44
+ jsx-node:
45
+ any:
46
+ - kind: jsx_element
47
+ - kind: jsx_self_closing_element
48
+ # `useStore()`, `useGalleryStore()`, `usePageStore()`, `useRootStore()`.
49
+ store-hook-call:
50
+ kind: call_expression
51
+ has:
52
+ field: function
53
+ kind: identifier
54
+ regex: '^use(Store|[A-Z][A-Za-z0-9]*Store)$'
55
+ # Every name a store hook can introduce, in the three spellings the park uses.
56
+ # Binding these is what lets the rule tell `ui.groupBy` (a plain `UIManifest`
57
+ # prop) apart from `uiStore.x` (the store) — without it the rule fires on any
58
+ # dotted value in the markup, which is how observatory's deliberately-plain
59
+ # `RichEntityListView` got flagged for rendering `groupBy={ui.groupBy}`.
60
+ store-binding:
61
+ any:
62
+ # const { data, ui } = useStore()
63
+ - all:
64
+ - kind: shorthand_property_identifier_pattern
65
+ - inside:
66
+ kind: object_pattern
67
+ inside:
68
+ kind: variable_declarator
69
+ has:
70
+ field: value
71
+ matches: store-hook-call
72
+ # const { ui: uiStore } = useStore() → the local alias
73
+ - all:
74
+ - kind: identifier
75
+ - inside:
76
+ kind: pair_pattern
77
+ field: value
78
+ inside:
79
+ kind: object_pattern
80
+ inside:
81
+ kind: variable_declarator
82
+ has:
83
+ field: value
84
+ matches: store-hook-call
85
+ # const store = useStore()
86
+ - all:
87
+ - kind: identifier
88
+ - inside:
89
+ kind: variable_declarator
90
+ field: name
91
+ has:
92
+ field: value
93
+ matches: store-hook-call
94
+ # Read DURING RENDER — which is what creates the subscription an observer
95
+ # wrap is needed to service — as opposed to touched only from a handler or an
96
+ # effect, neither of which subscribes anything.
97
+ #
98
+ # Stated as an exclusion of the two deferred positions rather than as "appears
99
+ # inside JSX", because real components mostly do NOT dereference the store in
100
+ # the markup: tg-gallery's GalleryView reads `const layoutType =
101
+ # themeStore.layout.type` and `match(galleryStore.detailState)`, observatory's
102
+ # SessionsTreeSection reads `data.sessions.filter(...)` — all render reads,
103
+ # none of them inside a JSX expression. An "inside JSX" test missed every one.
104
+ # The test is structural: a read sitting inside a NESTED function is deferred,
105
+ # a read at the component's own body level runs during render.
106
+ #
107
+ # It works because the component is itself a function. A body-level read has
108
+ # exactly one function ancestor — the component. Anything deferred has two or
109
+ # more: `useEffect(() => …)`, `onOpen={(e) => uiStore.open(e.id)}`, and
110
+ # `const onPress = () => ui.close(id)` all put a second function in between.
111
+ # So "is there a function ancestor that itself has a function ancestor" asks
112
+ # exactly the right question, and asks it without enumerating hook names or
113
+ # handler spellings — an enumerated list missed the local-handler form and the
114
+ # good fixture caught it.
115
+ render-position:
116
+ not:
117
+ inside:
118
+ stopBy: end
119
+ all:
120
+ - any:
121
+ - kind: arrow_function
122
+ - kind: function_expression
123
+ - kind: function_declaration
124
+ - inside:
125
+ stopBy: end
126
+ any:
127
+ - kind: arrow_function
128
+ - kind: function_expression
129
+ - kind: function_declaration
130
+ message: |
131
+ A component that reads observable state without an `observer(...)` wrap. It
132
+ renders once with whatever the store held at mount and then goes quiet —
133
+ nothing subscribes it to the fields it read, so the screen silently shows
134
+ stale data until some ancestor happens to re-render it.
135
+ Wrap the component: `export const X = observer(({ model }: Props) => …)`.
136
+ Scope note: this fires only when the store is named at the call site — a
137
+ `use…Store` hook, or a `model`/`store` prop being dereferenced. A component
138
+ handed an observable under any other prop name still needs the wrap and this
139
+ rule will not catch it. See the mobx-components skill.
140
+ rule:
141
+ all:
142
+ - any:
143
+ - kind: variable_declarator
144
+ - kind: function_declaration
145
+ # PascalCase — a component, not a custom hook. A hook may read the store
146
+ # freely; the wrap belongs on whoever renders with it.
147
+ - has:
148
+ field: name
149
+ regex: '^[A-Z][A-Za-z0-9]*$'
150
+ - has:
151
+ stopBy: end
152
+ matches: jsx-node
153
+ # The component must RENDER a field off something the store gave it. Both
154
+ # halves matter: a component that pulls a store only to call an action from
155
+ # a handler needs no wrap (observatory writes two of those on purpose), and
156
+ # a dotted value in the markup means nothing unless the dotted thing came
157
+ # from a store. `$SLICE` ties the two clauses together.
158
+ - any:
159
+ # Arm 1: a binding introduced by a `use…Store` hook, then rendered.
160
+ - all:
161
+ - has:
162
+ stopBy: end
163
+ all:
164
+ - matches: store-binding
165
+ - pattern: $SLICE
166
+ - has:
167
+ stopBy: end
168
+ all:
169
+ - pattern: $SLICE.$FIELD
170
+ - matches: render-position
171
+ # Arm 2: a prop literally named `model` or `store`, then rendered.
172
+ - all:
173
+ - has:
174
+ stopBy: end
175
+ kind: shorthand_property_identifier_pattern
176
+ regex: '^(model|store)$'
177
+ - has:
178
+ stopBy: end
179
+ all:
180
+ - kind: member_expression
181
+ - has:
182
+ field: object
183
+ kind: identifier
184
+ regex: '^(model|store)$'
185
+ - matches: render-position
186
+ - not:
187
+ has:
188
+ stopBy: end
189
+ pattern: observer($$$)
@@ -0,0 +1,23 @@
1
+ id: mobx-effect-observable-dep
2
+ language: tsx
3
+ severity: warning
4
+ message: |
5
+ useEffect whose dependency array reads observable state (`someStore.field`).
6
+ React is being used to react to MobX — but MobX already does that, correctly,
7
+ without a render pass in between. A dependency array cannot track an
8
+ observable it did not read during render, so this drifts.
9
+ Derived value → `computed get`. Side effect on state change → a `reaction()`
10
+ in the store (I/O boundaries only). Load on route change → the page-level
11
+ fetch convention, where the dependency is the route param, not a store field.
12
+ Passing the store itself (`[slug, galleryStore]`) is fine — that is a stable
13
+ reference, not a state read.
14
+ rule:
15
+ all:
16
+ - pattern: useEffect($CB, [$$$DEPS])
17
+ - has:
18
+ field: arguments
19
+ has:
20
+ kind: array
21
+ has:
22
+ kind: member_expression
23
+ regex: '[Ss]tore\.'
@@ -0,0 +1,21 @@
1
+ id: mobx-effect-store-write
2
+ language: tsx
3
+ severity: error
4
+ message: |
5
+ useEffect that writes to a store. This is `reaction()`-as-state-sync wearing a
6
+ useEffect costume — the same anti-pattern the mobx-models skill already bans,
7
+ just moved into the component tree where it re-runs on render instead of on
8
+ state change.
9
+ Move it into the store: a flow, an action called at the end of the flow that
10
+ produced the state, or a `computed get` if the value is derived.
11
+ See mobx-components "Anti-pattern: reaction() for state synchronization".
12
+ rule:
13
+ all:
14
+ - pattern: useEffect($$$)
15
+ - has:
16
+ stopBy: end
17
+ kind: assignment_expression
18
+ has:
19
+ field: left
20
+ kind: member_expression
21
+ regex: '[Ss]tore\.'
@@ -0,0 +1,20 @@
1
+ id: mobx-usestate-from-store
2
+ language: tsx
3
+ severity: warning
4
+ message: |
5
+ useState seeded from observable state. Now there are two copies of the same
6
+ value and nothing keeps them in sync — which is why the next line is usually a
7
+ useEffect that re-assigns it on every id change (with an eslint/biome ignore
8
+ comment admitting the dependency list is a lie).
9
+ A draft being edited is a real thing — put it in the store (or a small
10
+ UI-state model) so it is observable, or reset it with `key={id}` on the child
11
+ instead of syncing it by effect. Read-only display needs no local copy at all.
12
+ rule:
13
+ all:
14
+ - pattern: useState($INIT)
15
+ - has:
16
+ field: arguments
17
+ has:
18
+ stopBy: end
19
+ kind: member_expression
20
+ regex: '[Ss]tore\.'