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.
- package/README.md +4 -0
- package/ast-grep/rules/backend-no-test-file-imports.yml +44 -0
- package/ast-grep/rules/force-dynamic-layout.yml +57 -0
- package/ast-grep/rules/force-dynamic-pages.yml +38 -0
- package/ast-grep/rules/no-dynamic-import-in-vitest-config.yml +30 -0
- package/ast-grep/rules/no-dynamic-server-components-tsx.yml +71 -0
- package/ast-grep/rules/no-dynamic-server-components.yml +71 -0
- package/ast-grep/rules/no-enable-indexscan-off-tsx.yml +33 -0
- package/ast-grep/rules/no-enable-indexscan-off.yml +41 -0
- package/ast-grep/rules/no-exports-in-tests-folder-tsx.yml +42 -0
- package/ast-grep/rules/no-exports-in-tests-folder.yml +43 -0
- package/ast-grep/rules/no-object-assign-new-error.yml +31 -0
- package/ast-grep/rules/no-runner-temp-nullish.yml +33 -0
- package/ast-grep/rules/no-self-comparison-assertion-tsx.yml +43 -0
- package/ast-grep/rules/no-self-comparison-assertion.yml +53 -0
- package/ast-grep/rules/no-sql-offset.yml +65 -0
- package/ast-grep/rules/no-sync-throw-assert-on-async-enqueue.yml +42 -0
- package/ast-grep/rules/no-tautological-literal-assertion-tsx.yml +31 -0
- package/ast-grep/rules/no-tautological-literal-assertion.yml +38 -0
- package/ast-grep/rules/no-test-max-lines-disable-tsx.yml +41 -0
- package/ast-grep/rules/no-test-max-lines-disable.yml +53 -0
- package/ast-grep/rules/no-three-sequential-awaits-tsx.yml +288 -0
- package/ast-grep/rules/no-three-sequential-awaits.yml +242 -0
- package/ast-grep/rules/nullable-options.yml +429 -0
- package/ast-grep/rules/pagination-no-exact-limit-has-next.yml +111 -0
- package/ast-grep/rules/playwright-no-bare-meta-press.yml +29 -0
- package/ast-grep/rules/storybook-no-webpack-final.yml +31 -0
- package/ast-grep/rules/web-class-component-needs-use-client.yml +175 -0
- package/ast-grep/rules/web-clickable-needs-pointer.yml +74 -0
- package/ast-grep/rules/web-dialog-content-description.yml +56 -0
- package/ast-grep/rules/web-icon-button-tooltip.yml +82 -0
- package/ast-grep/rules/web-no-inline-scrollbar-hide.yml +41 -0
- package/ast-grep/rules/web-no-window-open-navigation.yml +39 -0
- package/ast-grep/sgconfig.yml +9 -0
- package/dist/ast-grep-pack/index.d.mts +6 -0
- package/dist/ast-grep-pack/index.mjs +11 -0
- package/dist/cli/commands/ast-grep-pack.d.mts +2 -0
- package/dist/cli/commands/ast-grep-pack.mjs +11 -0
- package/dist/cli/index.mjs +3 -0
- package/dist/cli/parse-options.d.mts +1 -0
- package/dist/cli/parse-options.mjs +6 -0
- package/dist/cli/parse.d.mts +2 -0
- package/dist/cli/parse.mjs +3 -1
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +2 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/package.json +8 -1
- package/skills/manifest.json +24 -18
- 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,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
|
+
}
|