vouchington-tooling 0.6.2 → 0.7.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 (50) hide show
  1. package/README.md +4 -0
  2. package/ast-grep/rules/backend-no-test-file-imports.yml +44 -0
  3. package/ast-grep/rules/force-dynamic-layout.yml +57 -0
  4. package/ast-grep/rules/force-dynamic-pages.yml +38 -0
  5. package/ast-grep/rules/no-dynamic-import-in-vitest-config.yml +30 -0
  6. package/ast-grep/rules/no-dynamic-server-components-tsx.yml +71 -0
  7. package/ast-grep/rules/no-dynamic-server-components.yml +71 -0
  8. package/ast-grep/rules/no-enable-indexscan-off-tsx.yml +33 -0
  9. package/ast-grep/rules/no-enable-indexscan-off.yml +41 -0
  10. package/ast-grep/rules/no-exports-in-tests-folder-tsx.yml +42 -0
  11. package/ast-grep/rules/no-exports-in-tests-folder.yml +43 -0
  12. package/ast-grep/rules/no-object-assign-new-error.yml +31 -0
  13. package/ast-grep/rules/no-runner-temp-nullish.yml +33 -0
  14. package/ast-grep/rules/no-self-comparison-assertion-tsx.yml +43 -0
  15. package/ast-grep/rules/no-self-comparison-assertion.yml +53 -0
  16. package/ast-grep/rules/no-sql-offset.yml +65 -0
  17. package/ast-grep/rules/no-sync-throw-assert-on-async-enqueue.yml +42 -0
  18. package/ast-grep/rules/no-tautological-literal-assertion-tsx.yml +31 -0
  19. package/ast-grep/rules/no-tautological-literal-assertion.yml +38 -0
  20. package/ast-grep/rules/no-test-max-lines-disable-tsx.yml +41 -0
  21. package/ast-grep/rules/no-test-max-lines-disable.yml +53 -0
  22. package/ast-grep/rules/no-three-sequential-awaits-tsx.yml +288 -0
  23. package/ast-grep/rules/no-three-sequential-awaits.yml +242 -0
  24. package/ast-grep/rules/nullable-options.yml +429 -0
  25. package/ast-grep/rules/pagination-no-exact-limit-has-next.yml +111 -0
  26. package/ast-grep/rules/playwright-no-bare-meta-press.yml +29 -0
  27. package/ast-grep/rules/storybook-no-webpack-final.yml +31 -0
  28. package/ast-grep/rules/web-class-component-needs-use-client.yml +175 -0
  29. package/ast-grep/rules/web-clickable-needs-pointer.yml +74 -0
  30. package/ast-grep/rules/web-dialog-content-description.yml +56 -0
  31. package/ast-grep/rules/web-icon-button-tooltip.yml +82 -0
  32. package/ast-grep/rules/web-no-inline-scrollbar-hide.yml +41 -0
  33. package/ast-grep/rules/web-no-window-open-navigation.yml +39 -0
  34. package/ast-grep/sgconfig.yml +9 -0
  35. package/dist/ast-grep-pack/index.d.mts +6 -0
  36. package/dist/ast-grep-pack/index.mjs +11 -0
  37. package/dist/cli/commands/ast-grep-pack.d.mts +2 -0
  38. package/dist/cli/commands/ast-grep-pack.mjs +11 -0
  39. package/dist/cli/index.mjs +3 -0
  40. package/dist/cli/parse-options.d.mts +1 -0
  41. package/dist/cli/parse-options.mjs +6 -0
  42. package/dist/cli/parse.d.mts +2 -0
  43. package/dist/cli/parse.mjs +3 -1
  44. package/dist/cli/usage.d.mts +1 -1
  45. package/dist/cli/usage.mjs +2 -0
  46. package/dist/index.d.mts +2 -0
  47. package/dist/index.mjs +1 -0
  48. package/package.json +8 -1
  49. package/skills/manifest.json +24 -18
  50. package/skills/stacked-prs/SKILL.md +43 -0
@@ -0,0 +1,111 @@
1
+ id: pagination-no-exact-limit-has-next
2
+ language: TypeScript
3
+ severity: error
4
+ message: 'Do not infer another page from an exact page-size boundary; fetch limit + 1 and test rows.length > limit.'
5
+ files:
6
+ - 'backend/**/*.mts'
7
+ ignores:
8
+ - 'backend/**/*.test.mts'
9
+ - 'backend/**/__tests__/**'
10
+ rule:
11
+ all:
12
+ - any:
13
+ - pattern: $ROWS.length === $BOUND
14
+ - pattern: $ROWS.length == $BOUND
15
+ - pattern: $ROWS.length >= $BOUND
16
+ - pattern: $BOUND === $ROWS.length
17
+ - pattern: $BOUND == $ROWS.length
18
+ - pattern: $BOUND <= $ROWS.length
19
+ - any:
20
+ - inside:
21
+ kind: variable_declarator
22
+ stopBy: end
23
+ has:
24
+ kind: identifier
25
+ regex: '^has(?:More|NextPage)$'
26
+ stopBy: neighbor
27
+ - inside:
28
+ kind: assignment_expression
29
+ stopBy: end
30
+ has:
31
+ field: left
32
+ kind: identifier
33
+ regex: '^has(?:More|NextPage)$'
34
+ - inside:
35
+ kind: pair
36
+ stopBy: end
37
+ has:
38
+ kind: property_identifier
39
+ regex: '^(?:has_next_page|hasNextPage)$'
40
+ stopBy: neighbor
41
+ constraints:
42
+ BOUND:
43
+ any:
44
+ - all:
45
+ - regex: '(?i)(?:limit|pageSize)'
46
+ - not:
47
+ kind: binary_expression
48
+ - all:
49
+ - kind: number
50
+ - regex: '^[1-9][0-9]*$'
51
+ examples:
52
+ - code: 'const hasMore = messages.length === effectiveLimit'
53
+ isValid: false
54
+ file: 'backend/api/v1/example/list.mts'
55
+ - code: 'const hasNextPage = rows.length >= limit'
56
+ isValid: false
57
+ file: 'backend/services/example/search.mts'
58
+ - code: 'hasMore = users.length === 100'
59
+ isValid: false
60
+ file: 'backend/services/example/search.mts'
61
+ - code: 'const hasMore = effectiveLimit === messages.length'
62
+ isValid: false
63
+ file: 'backend/api/v1/example/list.mts'
64
+ - code: 'const hasNextPage = options.pageSize <= rows.length'
65
+ isValid: false
66
+ file: 'backend/services/example/search.mts'
67
+ - code: 'const hasMore = rows.length == options.pagination.limit'
68
+ isValid: false
69
+ file: 'backend/services/example/search.mts'
70
+ - code: 'const hasNextPage = getEffectiveLimit(options) == rows.length'
71
+ isValid: false
72
+ file: 'backend/services/example/search.mts'
73
+ - code: 'const options = { hasNextPage: rows.length === limit }'
74
+ isValid: false
75
+ file: 'backend/services/example/search.mts'
76
+ - code: 'const pageInfo = { has_next_page: limit <= rows.length }'
77
+ isValid: false
78
+ file: 'backend/services/example/search.mts'
79
+ - code: 'const hasNextPage = rows.length > limit'
80
+ isValid: true
81
+ file: 'backend/services/example/search.mts'
82
+ - code: 'hasMore = users.length > limit'
83
+ isValid: true
84
+ file: 'backend/services/example/search.mts'
85
+ - code: 'const nextCursor = userIds.length === limit ? userIds.at(-1) : null'
86
+ isValid: true
87
+ file: 'backend/services/example/find-users-needing-recalculation.mts'
88
+ - code: 'const hasMore = rows.length === 0'
89
+ isValid: true
90
+ file: 'backend/services/example/search.mts'
91
+ - code: 'const hasMore = rows.length >= minimumResults'
92
+ isValid: true
93
+ file: 'backend/services/example/search.mts'
94
+ - code: 'const hasMore = rows.length === limit + 1'
95
+ isValid: true
96
+ file: 'backend/services/example/search.mts'
97
+ - code: 'const options = { hasMoreRows: rows.length === limit }'
98
+ isValid: true
99
+ file: 'backend/services/example/search.mts'
100
+ - code: 'nextCursor = users.length === 100 ? users.at(-1)?.id : null'
101
+ isValid: true
102
+ file: 'backend/services/example/search.mts'
103
+ - code: 'const hasMore = messages.length === effectiveLimit'
104
+ isValid: true
105
+ file: 'backend/api/v1/example/list.test.mts'
106
+ - code: 'hasMore = users.length === 100'
107
+ isValid: true
108
+ file: 'backend/services/example/search.test.mts'
109
+ - code: 'const hasNextPage = rows.length === limit'
110
+ isValid: true
111
+ file: 'backend/services/example/search.mock.test.mts'
@@ -0,0 +1,29 @@
1
+ id: playwright-no-bare-meta-press
2
+ language: TypeScript
3
+ severity: error
4
+ message: "use 'ControlOrMeta+' instead of 'Meta+' in Playwright .press() calls; bare Meta+ fails on Linux CI"
5
+ files:
6
+ - '**/*.spec.mts'
7
+ rule:
8
+ any:
9
+ - pattern: $OBJ.press($ARG)
10
+ - pattern: $OBJ.press($ARG, $$$REST)
11
+ constraints:
12
+ ARG:
13
+ regex: '[^A-Za-z]Meta\+'
14
+ examples:
15
+ - code: "await input.press('Meta+Enter')"
16
+ isValid: false
17
+ file: 'playwright/tests/forms/form.spec.mts'
18
+ - code: "await input.press('Meta+Enter', { delay: 10 })"
19
+ isValid: false
20
+ file: 'playwright/tests/forms/form.spec.mts'
21
+ - code: "await input.press('Shift+Meta+Enter')"
22
+ isValid: false
23
+ file: 'playwright/tests/forms/form.spec.mts'
24
+ - code: "await input.press('ControlOrMeta+Enter')"
25
+ isValid: true
26
+ file: 'playwright/tests/forms/form.spec.mts'
27
+ - code: "await input.press('Shift+ControlOrMeta+Enter')"
28
+ isValid: true
29
+ file: 'playwright/tests/forms/form.spec.mts'
@@ -0,0 +1,31 @@
1
+ id: storybook-no-webpack-final
2
+ language: TypeScript
3
+ severity: error
4
+ message: 'Storybook must use viteFinal, not webpackFinal; this project uses @storybook/nextjs-vite'
5
+ files:
6
+ - '**/.storybook/main.ts'
7
+ - '**/.storybook/main.mts'
8
+ - '**/.storybook/main.cts'
9
+ rule:
10
+ regex: '^webpackFinal$|^["''`]webpackFinal["''`]$'
11
+ any:
12
+ - kind: property_identifier
13
+ - kind: shorthand_property_identifier
14
+ - kind: identifier
15
+ - kind: string
16
+ examples:
17
+ - code: 'const config = { webpackFinal: (c) => c }'
18
+ isValid: false
19
+ file: 'web/.storybook/main.ts'
20
+ - code: "const config = { 'webpackFinal': (c) => c }"
21
+ isValid: false
22
+ file: 'web/.storybook/main.ts'
23
+ - code: 'const webpackFinal = (c) => c; export default { framework, webpackFinal }'
24
+ isValid: false
25
+ file: 'web/.storybook/main.ts'
26
+ - code: 'const config = { viteFinal: (c) => c }'
27
+ isValid: true
28
+ file: 'web/.storybook/main.ts'
29
+ - code: '// webpackFinal'
30
+ isValid: true
31
+ file: 'web/.storybook/main.ts'
@@ -0,0 +1,175 @@
1
+ # NOTE: This is a prevention guard on an already-clean surface — the only App Router
2
+ # class component today is `web/components/ui/error-boundary.tsx`, which already has
3
+ # `'use client'`. The Storybook ratchet class lives under `web/storybook/**` and is
4
+ # ignored. Zero live hits from `ast-grep scan` is expected and correct; the rule's
5
+ # ability to fire is proven by the `isValid: false` examples below, not by live
6
+ # scan hits.
7
+ # Known misses, not implied coverage: `import { Component as C }` when the
8
+ # local name is not `Component`/`PureComponent`, and `implements` clauses.
9
+ # tree-sitter-tsx puts heritage under `class_heritage` → `extends_clause`.
10
+ # `Component` is an `identifier`; `React.Component` uses `property_identifier`.
11
+ # The `'use client'` exemption is the leading directive only: Next ignores a
12
+ # later program-level string after an import or other statement. A comment may
13
+ # precede the directive. `interface … extends ComponentProps` is not a
14
+ # `class_declaration` and cannot match.
15
+ id: web-class-component-needs-use-client
16
+ language: Tsx
17
+ severity: error
18
+ message: >-
19
+ React class components must live in a `'use client'` module. `'use server'` is
20
+ a server-action directive and does not make a class a valid Server Component.
21
+ files:
22
+ - 'web/**/*.tsx'
23
+ ignores:
24
+ - '**/*.test.tsx'
25
+ - '**/*.spec.tsx'
26
+ - '**/*.mock.test.tsx'
27
+ - '**/*.stories.tsx'
28
+ - 'web/storybook/**'
29
+ rule:
30
+ kind: program
31
+ all:
32
+ - has:
33
+ stopBy: end
34
+ kind: class_declaration
35
+ has:
36
+ kind: class_heritage
37
+ has:
38
+ kind: extends_clause
39
+ has:
40
+ stopBy: end
41
+ any:
42
+ - kind: identifier
43
+ regex: '^(?:Component|PureComponent)$'
44
+ - kind: property_identifier
45
+ regex: '^(?:Component|PureComponent)$'
46
+ - not:
47
+ has:
48
+ kind: expression_statement
49
+ has:
50
+ any:
51
+ - pattern: "'use client'"
52
+ - pattern: '"use client"'
53
+ # Next only honors a leading directive. A later program-level
54
+ # string after an import or other statement is not a directive.
55
+ # `stopBy: end` looks past intervening comments so
56
+ # `import ...; // note; 'use client'` stays a violation. A
57
+ # file-leading comment before the directive still exempts.
58
+ not:
59
+ follows:
60
+ stopBy: end
61
+ any:
62
+ - kind: import_statement
63
+ - kind: export_statement
64
+ - kind: class_declaration
65
+ - kind: lexical_declaration
66
+ - kind: variable_declaration
67
+ - kind: function_declaration
68
+ - kind: interface_declaration
69
+ - kind: type_alias_declaration
70
+ - kind: expression_statement
71
+ examples:
72
+ - code: |
73
+ import { Component, type ReactNode } from 'react'
74
+ export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
75
+ render(): ReactNode { return this.props.children }
76
+ }
77
+ isValid: false
78
+ file: 'web/components/ui/example.tsx'
79
+ - code: |
80
+ import { Component, type ReactNode } from 'react'
81
+ export default class FallbackBoundary extends Component<{ children: ReactNode }> {
82
+ render(): ReactNode { return this.props.children }
83
+ }
84
+ isValid: false
85
+ file: 'web/components/ui/default-export.tsx'
86
+ - code: |
87
+ import { PureComponent, type ReactNode } from 'react'
88
+ export class PureBoundary extends PureComponent<{ children: ReactNode }> {
89
+ render(): ReactNode { return this.props.children }
90
+ }
91
+ isValid: false
92
+ file: 'web/components/ui/pure-example.tsx'
93
+ - code: |
94
+ import React from 'react'
95
+ export class NsBoundary extends React.Component<{ children: React.ReactNode }> {
96
+ render() { return this.props.children }
97
+ }
98
+ isValid: false
99
+ file: 'web/components/ui/react-ns.tsx'
100
+ - code: |
101
+ export class NestedDirective extends Component {
102
+ render() {
103
+ const mark = 'use client'
104
+ return mark
105
+ }
106
+ }
107
+ isValid: false
108
+ file: 'web/components/ui/nested-directive.tsx'
109
+ - code: |
110
+ import { Component, type ReactNode } from 'react'
111
+ 'use client'
112
+ export class LateBoundary extends Component<{ children: ReactNode }> {
113
+ render(): ReactNode { return this.props.children }
114
+ }
115
+ isValid: false
116
+ file: 'web/components/ui/late-directive.tsx'
117
+ - code: |
118
+ import { Component, type ReactNode } from 'react'
119
+ // explanation
120
+ 'use client'
121
+ export class InterleavedBoundary extends Component<{ children: ReactNode }> {
122
+ render(): ReactNode { return this.props.children }
123
+ }
124
+ isValid: false
125
+ file: 'web/components/ui/interleaved-directive.tsx'
126
+ - code: |
127
+ 'use client'
128
+ import { Component, type ReactNode } from 'react'
129
+ export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
130
+ render(): ReactNode { return this.props.children }
131
+ }
132
+ isValid: true
133
+ file: 'web/components/ui/error-boundary.tsx'
134
+ - code: |
135
+ "use client"
136
+ import { Component, type ReactNode } from 'react'
137
+ export class QuotedBoundary extends Component<{ children: ReactNode }> {
138
+ render(): ReactNode { return this.props.children }
139
+ }
140
+ isValid: true
141
+ file: 'web/components/ui/quoted-boundary.tsx'
142
+ - code: |
143
+ import { type ComponentProps } from 'react'
144
+ interface Props extends ComponentProps<typeof Date> {
145
+ extra: boolean
146
+ }
147
+ isValid: true
148
+ file: 'web/components/ui/component-props.tsx'
149
+ - code: |
150
+ export interface ExternalLinkProps extends React.ComponentPropsWithoutRef<'a'> {
151
+ href: string
152
+ }
153
+ isValid: true
154
+ file: 'web/components/ui/external-link-props.tsx'
155
+ - code: |
156
+ import { Component, type ReactNode } from 'react'
157
+ export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
158
+ render(): ReactNode { return this.props.children }
159
+ }
160
+ isValid: true
161
+ file: 'web/components/ui/error-boundary.test.tsx'
162
+ - code: |
163
+ import { Component, type ReactNode } from 'react'
164
+ export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
165
+ render(): ReactNode { return this.props.children }
166
+ }
167
+ isValid: true
168
+ file: 'web/components/ui/error-boundary.stories.tsx'
169
+ - code: |
170
+ import { Component, type ReactNode } from 'react'
171
+ export class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
172
+ render(): ReactNode { return this.props.children }
173
+ }
174
+ isValid: true
175
+ file: 'web/storybook/component-story-ratchet-renderer.tsx'
@@ -0,0 +1,74 @@
1
+ id: web-clickable-needs-pointer
2
+ language: Tsx
3
+ severity: error
4
+ message: >-
5
+ Clickable elements need a pointer cursor. Use a real <button>/<Button>, or
6
+ add `cursor-pointer` (native buttons and [role=button] already get it from
7
+ globals.css).
8
+ files:
9
+ - 'web/**/*.tsx'
10
+ ignores:
11
+ - 'web/components/ui/**'
12
+ - '**/*.test.tsx'
13
+ - '**/*.spec.tsx'
14
+ - '**/*.stories.tsx'
15
+ rule:
16
+ all:
17
+ - any:
18
+ - kind: jsx_opening_element
19
+ - kind: jsx_self_closing_element
20
+ - has:
21
+ field: name
22
+ regex: '^(div|span|li|ul|ol|tr|td|th|section|article|header|footer|aside|nav|main|p|figure)$'
23
+ - has:
24
+ kind: jsx_attribute
25
+ regex: '^onClick'
26
+ - not:
27
+ has:
28
+ kind: jsx_attribute
29
+ regex: 'cursor-pointer'
30
+ - not:
31
+ has:
32
+ kind: jsx_attribute
33
+ regex: '^role=[''"]button[''"]'
34
+ examples:
35
+ - code: |
36
+ <div onClick={fn}>click me</div>
37
+ isValid: false
38
+ file: web/app/clickable-example.tsx
39
+ - code: |
40
+ <span onClick={fn} className="text-sm">click me</span>
41
+ isValid: false
42
+ file: web/app/clickable-example.tsx
43
+ - code: |
44
+ <div onClick={fn} role="menuitem">click me</div>
45
+ isValid: false
46
+ file: web/app/clickable-example.tsx
47
+ - code: |
48
+ <div onClick={fn} className="cursor-pointer">click me</div>
49
+ isValid: true
50
+ file: web/app/clickable-example.tsx
51
+ - code: |
52
+ <button onClick={fn}>click me</button>
53
+ isValid: true
54
+ file: web/app/clickable-example.tsx
55
+ - code: |
56
+ <Button onClick={fn}>click me</Button>
57
+ isValid: true
58
+ file: web/app/clickable-example.tsx
59
+ - code: |
60
+ <div onClick={fn} role="button">click me</div>
61
+ isValid: true
62
+ file: web/app/clickable-example.tsx
63
+ - code: |
64
+ <div onClick={fn}>click me</div>
65
+ isValid: true
66
+ file: web/components/ui/foo.tsx
67
+ - code: |
68
+ <div onClick={fn}>click me</div>
69
+ isValid: true
70
+ file: SomeComponent.test.tsx
71
+ - code: |
72
+ <div onClick={fn}>click me</div>
73
+ isValid: true
74
+ file: SomeComponent.stories.tsx
@@ -0,0 +1,56 @@
1
+ id: web-dialog-content-description
2
+ language: Tsx
3
+ severity: error
4
+ message: 'DialogContent and AlertDialogContent must include a DialogDescription or AlertDialogDescription child.'
5
+ files:
6
+ - 'web/**/*.tsx'
7
+ ignores:
8
+ - 'web/**/*.test.tsx'
9
+ - 'web/**/*.mock.test.tsx'
10
+ - 'web/**/*.stories.tsx'
11
+ - 'web/components/ui/**/*.tsx'
12
+ rule:
13
+ any:
14
+ - all:
15
+ - pattern: '<DialogContent $$$ATTRS>$$$CHILDREN</DialogContent>'
16
+ - not:
17
+ has:
18
+ pattern: '<DialogDescription $$$>$$$</DialogDescription>'
19
+ stopBy: end
20
+ - all:
21
+ - pattern: '<AlertDialogContent $$$ATTRS>$$$CHILDREN</AlertDialogContent>'
22
+ - not:
23
+ has:
24
+ pattern: '<AlertDialogDescription $$$>$$$</AlertDialogDescription>'
25
+ stopBy: end
26
+ examples:
27
+ - code: |
28
+ export function X() {
29
+ return <DialogContent><DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader></DialogContent>
30
+ }
31
+ isValid: false
32
+ file: 'web/components/example-dialog.tsx'
33
+ - code: |
34
+ export function X() {
35
+ return <AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Title</AlertDialogTitle></AlertDialogHeader></AlertDialogContent>
36
+ }
37
+ isValid: false
38
+ file: 'web/components/example-alert-dialog.tsx'
39
+ - code: |
40
+ export function X() {
41
+ return <DialogContent><DialogHeader><DialogTitle>Title</DialogTitle><DialogDescription>Help text</DialogDescription></DialogHeader></DialogContent>
42
+ }
43
+ isValid: true
44
+ file: 'web/components/example-dialog.tsx'
45
+ - code: |
46
+ export function X() {
47
+ return <AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Title</AlertDialogTitle><AlertDialogDescription>Help text</AlertDialogDescription></AlertDialogHeader></AlertDialogContent>
48
+ }
49
+ isValid: true
50
+ file: 'web/components/example-alert-dialog.tsx'
51
+ - code: |
52
+ export function X() {
53
+ return <DialogContent><DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader></DialogContent>
54
+ }
55
+ isValid: true
56
+ file: 'web/components/example-dialog.mock.test.tsx'
@@ -0,0 +1,82 @@
1
+ id: web-icon-button-tooltip
2
+ language: Tsx
3
+ severity: error
4
+ message: 'Use TooltipButton or an aria-label/aria-labelledby accessible name for icon-only buttons.'
5
+ files:
6
+ - 'web/**/*.tsx'
7
+ ignores:
8
+ - 'web/**/*.test.tsx'
9
+ - 'web/**/*.mock.test.tsx'
10
+ - 'web/**/*.stories.tsx'
11
+ - 'web/components/ui/**/*.tsx'
12
+ rule:
13
+ any:
14
+ - all:
15
+ - any:
16
+ - kind: jsx_opening_element
17
+ - kind: jsx_self_closing_element
18
+ - has:
19
+ field: name
20
+ regex: '^Button$'
21
+ - has:
22
+ kind: jsx_attribute
23
+ regex: '^size=["''](?:icon|touchIcon)["'']'
24
+ - not:
25
+ has:
26
+ kind: jsx_attribute
27
+ regex: '^asChild$'
28
+ - not:
29
+ has:
30
+ kind: jsx_attribute
31
+ regex: '^aria-label='
32
+ - not:
33
+ has:
34
+ kind: jsx_attribute
35
+ regex: '^aria-labelledby='
36
+ - not:
37
+ inside:
38
+ kind: jsx_element
39
+ has:
40
+ kind: jsx_opening_element
41
+ has:
42
+ field: name
43
+ regex: '^TooltipTrigger$'
44
+ - all:
45
+ - any:
46
+ - kind: jsx_opening_element
47
+ - kind: jsx_self_closing_element
48
+ - has:
49
+ field: name
50
+ regex: '^TooltipButton$'
51
+ - has:
52
+ kind: jsx_attribute
53
+ regex: '^size=["''](?:icon|touchIcon)["'']'
54
+ - not:
55
+ has:
56
+ kind: jsx_attribute
57
+ regex: '^tooltip='
58
+ examples:
59
+ - code: 'export function X() { return <Button size="icon" /> }'
60
+ isValid: false
61
+ file: 'web/components/example/save-button.tsx'
62
+ - code: 'export function X() { return <Button size="touchIcon" /> }'
63
+ isValid: false
64
+ file: 'web/components/example/save-button.tsx'
65
+ - code: 'export function X() { return <Button size="icon" aria-label="Save" /> }'
66
+ isValid: true
67
+ file: 'web/components/example/save-button.tsx'
68
+ - code: 'export function X() { return <TooltipButton size="icon" /> }'
69
+ isValid: false
70
+ file: 'web/components/example/save-button.tsx'
71
+ - code: 'export function X() { return <TooltipButton size="icon" tooltip="Save" /> }'
72
+ isValid: true
73
+ file: 'web/components/example/save-button.tsx'
74
+ - code: 'export function X() { return <TooltipTrigger><Button size="icon" /></TooltipTrigger> }'
75
+ isValid: true
76
+ file: 'web/components/example/save-button.tsx'
77
+ - code: 'export function X() { return <Button size="default" /> }'
78
+ isValid: true
79
+ file: 'web/components/example/save-button.tsx'
80
+ - code: 'export function X() { return <Button size="icon" /> }'
81
+ isValid: true
82
+ file: 'web/components/ui/button.tsx'
@@ -0,0 +1,41 @@
1
+ id: web-no-inline-scrollbar-hide
2
+ language: Tsx
3
+ severity: error
4
+ message: "Use the Tailwind 'scrollbar-hide' utility class instead of inline scrollbar-hiding CSS"
5
+ files:
6
+ - 'web/**/*.tsx'
7
+ ignores:
8
+ - '**/*.test.tsx'
9
+ - '**/*.spec.tsx'
10
+ - '**/*.stories.tsx'
11
+ rule:
12
+ # `pattern: 'style={$OBJ}'` does not work as a JSX attribute matcher in Tsx
13
+ # (ast-grep parses it as a TypeScript assignment). Use `kind: jsx_attribute`
14
+ # with an explicit `style` name check (attribute names are `property_identifier`
15
+ # in the TSX grammar) so that only the `style` attribute is flagged, not other
16
+ # JSX attributes that happen to contain those property names in their values.
17
+ all:
18
+ - kind: jsx_attribute
19
+ # No stopBy:end: the attribute name is an immediate child of jsx_attribute.
20
+ # Use regex (not pattern:) because `pattern: style` parses as an `identifier`
21
+ # AST node, which does not match the `property_identifier` grammar kind used
22
+ # for JSX attribute names.
23
+ - has:
24
+ kind: property_identifier
25
+ regex: '^style$'
26
+ # The scrollbar properties are nested inside the style object so stopBy:end
27
+ # is required to find them anywhere inside the attribute value.
28
+ - has:
29
+ stopBy: end
30
+ kind: property_identifier
31
+ regex: '^(scrollbarWidth|WebkitScrollbar|msOverflowStyle)$'
32
+ examples:
33
+ - code: "<div style={{ scrollbarWidth: 'none' }} />"
34
+ isValid: false
35
+ file: 'web/app/page.tsx'
36
+ - code: "<div style={{ msOverflowStyle: 'none' }} />"
37
+ isValid: false
38
+ file: 'web/app/page.tsx'
39
+ - code: '<div className="scrollbar-hide" />'
40
+ isValid: true
41
+ file: 'web/app/page.tsx'
@@ -0,0 +1,39 @@
1
+ id: web-no-window-open-navigation
2
+ language: Tsx
3
+ severity: error
4
+ message: >
5
+ Use an ExternalLink component or <a target="_blank"> instead of window.open() for new-tab navigation.
6
+ files:
7
+ - 'web/**/*.tsx'
8
+ - 'web/**/*.ts'
9
+ ignores:
10
+ - '**/*.test.tsx'
11
+ - '**/*.test.ts'
12
+ - '**/*.stories.tsx'
13
+ - '**/*.stories.ts'
14
+ rule:
15
+ any:
16
+ - pattern: window.open($URL, '_blank', $$$)
17
+ - pattern: window.open($URL, '_blank')
18
+ examples:
19
+ - code: "window.open(url, '_blank', 'noopener,noreferrer')"
20
+ isValid: false
21
+ file: 'web/app/page.tsx'
22
+ - code: "window.open(url, '_blank')"
23
+ isValid: false
24
+ file: 'web/app/page.tsx'
25
+ - code: "window.open(href, '_blank', 'noopener,noreferrer')"
26
+ isValid: false
27
+ file: 'web/components/search/command-link-item.tsx'
28
+ - code: "window.open(messagesHref(conversation), '_blank')"
29
+ isValid: false
30
+ file: 'web/components/example/mod-queue-posts.tsx'
31
+ - code: "window.open(messagesHref(conversation), '_blank')"
32
+ isValid: false
33
+ file: 'web/components/example/mod-queue-reports.tsx'
34
+ - code: '<ExternalLink href={url}>Link</ExternalLink>'
35
+ isValid: true
36
+ file: 'web/app/page.tsx'
37
+ - code: '<a href={url} target="_blank" rel="noopener noreferrer">Link</a>'
38
+ isValid: true
39
+ file: 'web/app/page.tsx'
@@ -0,0 +1,9 @@
1
+ ruleDirs:
2
+ - rules
3
+ languageGlobs:
4
+ TypeScript:
5
+ - '**/*.ts'
6
+ - '**/*.mts'
7
+ - '**/*.cts'
8
+ Tsx:
9
+ - '**/*.tsx'
@@ -0,0 +1,6 @@
1
+ export type AstGrepPackPaths = {
2
+ readonly rules: string;
3
+ readonly config: string;
4
+ };
5
+ export declare function astGrepPackPathsFrom(rules: string, config: string): AstGrepPackPaths;
6
+ export declare function astGrepPackPaths(): AstGrepPackPaths;
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ export function astGrepPackPathsFrom(rules, config) {
4
+ if (!existsSync(rules) || !existsSync(config)) {
5
+ throw new Error('ast-grep pack is missing from the installed package');
6
+ }
7
+ return { rules, config };
8
+ }
9
+ export function astGrepPackPaths() {
10
+ return astGrepPackPathsFrom(fileURLToPath(new URL('../../ast-grep/rules', import.meta.url)), fileURLToPath(new URL('../../ast-grep/sgconfig.yml', import.meta.url)));
11
+ }
@@ -0,0 +1,2 @@
1
+ import { type AstGrepPackPaths } from '../../ast-grep-pack/index.mts';
2
+ export declare function runAstGrepPackCommand(load?: () => AstGrepPackPaths): number;