@brighterly/lib-fe-codestyle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -0
- package/dist/invariants/ast.d.ts +72 -0
- package/dist/invariants/ast.js +195 -0
- package/dist/invariants/index.d.ts +7 -0
- package/dist/invariants/index.js +5 -0
- package/dist/invariants/rules/convention.d.ts +18 -0
- package/dist/invariants/rules/convention.js +226 -0
- package/dist/invariants/rules/fixtures.d.ts +17 -0
- package/dist/invariants/rules/fixtures.js +65 -0
- package/dist/invariants/rules/index.d.ts +55 -0
- package/dist/invariants/rules/index.js +53 -0
- package/dist/invariants/rules/linter.d.ts +10 -0
- package/dist/invariants/rules/linter.js +9 -0
- package/dist/invariants/rules/regression.d.ts +13 -0
- package/dist/invariants/rules/regression.js +109 -0
- package/dist/invariants/rules/shared.d.ts +18 -0
- package/dist/invariants/rules/shared.js +25 -0
- package/dist/invariants/run.d.ts +56 -0
- package/dist/invariants/run.js +553 -0
- package/dist/invariants/self-test.d.ts +38 -0
- package/dist/invariants/self-test.js +57 -0
- package/dist/invariants/types.d.ts +192 -0
- package/dist/invariants/types.js +9 -0
- package/dist/invariants/util.d.ts +113 -0
- package/dist/invariants/util.js +269 -0
- package/dist/validate/index.d.ts +36 -0
- package/dist/validate/index.js +41 -0
- package/eslint/index.mjs +187 -0
- package/eslint/rules/async-components-after-macros.mjs +63 -0
- package/eslint/rules/no-import-group-labels.mjs +72 -0
- package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
- package/eslint/rules/optional-callback-chaining.mjs +78 -0
- package/eslint/rules/padding-after-macros.mjs +70 -0
- package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
- package/eslint/rules/require-component-name.mjs +68 -0
- package/package.json +64 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aggregating runner behind `pnpm validate`.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists rather than `a && b && c`: pnpm offers exactly one of the two properties
|
|
5
|
+
* a gate needs, never both. Default bail stops at the first failure and exits 1 — correct
|
|
6
|
+
* exit code, incomplete feedback, so you fix lint, push, and only then discover typecheck.
|
|
7
|
+
* `--no-bail` runs everything but **exits 0**, which is unusable as a gate (the same
|
|
8
|
+
* footgun the invariants CLI records as "dropped 2026-08-19: --warn-only, always exits 0").
|
|
9
|
+
*
|
|
10
|
+
* So: run every step, report all of them, and fail if any failed. One push, one run, every
|
|
11
|
+
* problem visible — locally as much as in CI.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
const defaultExec = (cmd) => ({ ok: spawnSync(cmd, { shell: true, stdio: 'inherit' }).status === 0 });
|
|
15
|
+
const NAME_WIDTH = 12;
|
|
16
|
+
export const validate = async (steps, options = {}) => {
|
|
17
|
+
const exec = options.exec ?? defaultExec;
|
|
18
|
+
const write = options.write ?? ((s) => { process.stdout.write(s); });
|
|
19
|
+
const exit = options.exit ?? process.exit;
|
|
20
|
+
const ci = options.ci ?? Boolean(process.env.GITHUB_ACTIONS);
|
|
21
|
+
const results = [];
|
|
22
|
+
for (const step of steps) {
|
|
23
|
+
if (ci)
|
|
24
|
+
write(`::group::${step.name}\n`);
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
const { ok } = exec(step.run);
|
|
27
|
+
results.push({ name: step.name, ok, ms: Date.now() - started });
|
|
28
|
+
if (ci)
|
|
29
|
+
write('::endgroup::\n');
|
|
30
|
+
}
|
|
31
|
+
const failed = results.filter(r => !r.ok);
|
|
32
|
+
write('\n');
|
|
33
|
+
for (const r of results) {
|
|
34
|
+
write(` ${r.ok ? '✓' : '✗'} ${r.name.padEnd(NAME_WIDTH)}${(r.ms / 1000).toFixed(1)}s\n`);
|
|
35
|
+
}
|
|
36
|
+
write(failed.length
|
|
37
|
+
? `\n ${failed.length} of ${results.length} failed — ${failed.map(f => f.name).join(', ')}\n\n`
|
|
38
|
+
: `\n all ${results.length} passed\n\n`);
|
|
39
|
+
exit(failed.length ? 1 : 0);
|
|
40
|
+
return results;
|
|
41
|
+
};
|
package/eslint/index.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared flat config for the Brighterly Nuxt apps.
|
|
3
|
+
*
|
|
4
|
+
* Exports DATA and a plain ARRAY, never a factory. ESLint *replaces* rule options rather
|
|
5
|
+
* than merging them, so an app appending `check-file/folder-naming-convention` without
|
|
6
|
+
* spreading `checkFileFolders` would silently discard every shared entry. That is why the
|
|
7
|
+
* maps below are exported on their own.
|
|
8
|
+
*
|
|
9
|
+
* The default array composes both ways:
|
|
10
|
+
* land-app export default withNuxt(...brighterly, ...overrides)
|
|
11
|
+
* cs-app export default [...brighterly, ...overrides]
|
|
12
|
+
*
|
|
13
|
+
* WHAT IS DELIBERATELY NOT HERE: import ordering. land-app configures `import/order`
|
|
14
|
+
* (eslint-plugin-import, via Nuxt) while cs-app, app-tutor and app-admin configure
|
|
15
|
+
* `import-x/order`, and the `pathGroups` name each app's own aliases. Sharing it would
|
|
16
|
+
* newly impose a plugin on land-app and cs-app's alias list on everyone. It is exported as
|
|
17
|
+
* `importOrderPathGroups` for an app that wants the baseline, and nothing more.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import checkFile from 'eslint-plugin-check-file'
|
|
21
|
+
|
|
22
|
+
import asyncComponentsAfterMacros from './rules/async-components-after-macros.mjs'
|
|
23
|
+
import noImportGroupLabels from './rules/no-import-group-labels.mjs'
|
|
24
|
+
import onPrefixedCallbacks from './rules/on-prefixed-callbacks.mjs'
|
|
25
|
+
import optionalCallbackChaining from './rules/optional-callback-chaining.mjs'
|
|
26
|
+
import paddingAfterMacros from './rules/padding-after-macros.mjs'
|
|
27
|
+
import refPrefixPropMirrors from './rules/ref-prefix-prop-mirrors.mjs'
|
|
28
|
+
import requireComponentName from './rules/require-component-name.mjs'
|
|
29
|
+
|
|
30
|
+
export const localRules = {
|
|
31
|
+
'async-components-after-macros': asyncComponentsAfterMacros,
|
|
32
|
+
'no-import-group-labels': noImportGroupLabels,
|
|
33
|
+
'on-prefixed-callbacks': onPrefixedCallbacks,
|
|
34
|
+
'optional-callback-chaining': optionalCallbackChaining,
|
|
35
|
+
'padding-after-macros': paddingAfterMacros,
|
|
36
|
+
'ref-prefix-prop-mirrors': refPrefixPropMirrors,
|
|
37
|
+
'require-component-name': requireComponentName,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const noDoubleCast = {
|
|
41
|
+
selector: 'TSAsExpression > TSAsExpression[typeAnnotation.type="TSUnknownKeyword"]',
|
|
42
|
+
message: 'Do not use "as unknown as X" — use a single assertion or fix the underlying type.',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const noEmitIntoComposable = {
|
|
46
|
+
selector: 'CallExpression[callee.name=/^use[A-Z]/] > Identifier[name="emit"]',
|
|
47
|
+
message: 'Do not pass emit into a composable — pass on-prefixed callbacks and wire them to emit in the component.',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const vMotionInlineObject = {
|
|
51
|
+
selector: 'VAttribute[directive=true][key.name.name="motion"] > VExpressionContainer > ObjectExpression',
|
|
52
|
+
message: 'Declare the v-motion preset as a named object in script, not inline in the template.',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dataQaKebabCase = {
|
|
56
|
+
selector: 'VAttribute[directive=false][key.name="data-qa"] > VLiteral[value=/[^a-z0-9-]/]',
|
|
57
|
+
message: 'Static data-qa attributes must be kebab-case.',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const iconInsideContainer = {
|
|
61
|
+
selector: 'VElement:not([name=/^(iconcontainer|icon-container)$/]) > VElement[name=/^(fontawesomeicon|font-awesome-icon)$/]',
|
|
62
|
+
message: 'Wrap FontAwesomeIcon in IconContainer.',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const restrictedSyntax = [noDoubleCast, noEmitIntoComposable]
|
|
66
|
+
export const vueRestrictedSyntax = [vMotionInlineObject, dataQaKebabCase, iconInsideContainer]
|
|
67
|
+
|
|
68
|
+
/** enums.ts / interfaces.ts / types.ts hold only their own kind. */
|
|
69
|
+
export const typeFileSelectors = {
|
|
70
|
+
'enums.ts': [
|
|
71
|
+
{ selector: 'Program > TSInterfaceDeclaration, Program > ExportNamedDeclaration > TSInterfaceDeclaration', message: 'Interfaces belong in interfaces.ts, not enums.ts.' },
|
|
72
|
+
{ selector: 'Program > TSTypeAliasDeclaration, Program > ExportNamedDeclaration > TSTypeAliasDeclaration', message: 'Type aliases belong in types.ts, not enums.ts.' },
|
|
73
|
+
],
|
|
74
|
+
'interfaces.ts': [
|
|
75
|
+
{ selector: 'Program > TSEnumDeclaration, Program > ExportNamedDeclaration > TSEnumDeclaration', message: 'Enums belong in enums.ts, not interfaces.ts.' },
|
|
76
|
+
{ selector: 'Program > TSTypeAliasDeclaration, Program > ExportNamedDeclaration > TSTypeAliasDeclaration', message: 'Type aliases belong in types.ts, not interfaces.ts.' },
|
|
77
|
+
],
|
|
78
|
+
'types.ts': [
|
|
79
|
+
{ selector: 'Program > TSEnumDeclaration, Program > ExportNamedDeclaration > TSEnumDeclaration', message: 'Enums belong in enums.ts, not types.ts.' },
|
|
80
|
+
{ selector: 'Program > TSInterfaceDeclaration, Program > ExportNamedDeclaration > TSInterfaceDeclaration', message: 'Interfaces belong in interfaces.ts, not types.ts.' },
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const checkFileFilenames = {
|
|
85
|
+
'app/components/**/*.vue': 'PASCAL_CASE',
|
|
86
|
+
// Nuxt route files: kebab-case, with dynamic segments like [booking].vue allowed.
|
|
87
|
+
'app/pages/**/*.vue': '@(+([a-z0-9-])|[[]+([a-zA-Z0-9-])[]])',
|
|
88
|
+
'app/composables/**/*.ts': 'KEBAB_CASE',
|
|
89
|
+
'app/components/**/*.ts': 'KEBAB_CASE',
|
|
90
|
+
'app/{stores,types,utils,plugins,middleware}/**/*.ts': 'KEBAB_CASE',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const checkFileFolders = {
|
|
94
|
+
// PascalCase component folders plus the fixed set of package-infrastructure folders.
|
|
95
|
+
'app/components/!(pages)/**/': '@([A-Z]*([a-zA-Z0-9])|components|composables|types|styles|figma|images|config)',
|
|
96
|
+
'app/components/pages/**/': 'KEBAB_CASE',
|
|
97
|
+
'app/{composables,stores,types,utils}/**/': 'KEBAB_CASE',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const restrictedImports = {
|
|
101
|
+
patterns: [
|
|
102
|
+
{ regex: '^\\.\\.', message: 'Use project path aliases (@hooks, @UI, @ts-*, ...) instead of parent-relative imports.' },
|
|
103
|
+
{ regex: '^(~|~~|@)/', message: 'Default Nuxt aliases are banned — use the project aliases from nuxt.config.' },
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Baseline for `import(-x)/order`. Opt-in — see the note at the top of this file. */
|
|
108
|
+
export const importOrderPathGroups = [
|
|
109
|
+
{ pattern: '{@ts-*/**,@brighterly/lib-core-types/**,@stores/**/enums,@hooks/**/enums,@composables/**/enums,@utils/**/enums,@helpers/**/enums,@tools/**/enums}', group: 'internal', position: 'before' },
|
|
110
|
+
{ pattern: '@stores/**', group: 'internal', position: 'before' },
|
|
111
|
+
{ pattern: '{@hooks,@composables,@api,@ws}/**', group: 'internal', position: 'before' },
|
|
112
|
+
{ pattern: '{axios,dayjs,dayjs/**,@helpers/**,@utils/**,@auth/**,@config/**}', group: 'internal', position: 'before' },
|
|
113
|
+
{ pattern: '@tools/**', group: 'internal', position: 'before' },
|
|
114
|
+
{ pattern: '{@fortawesome/**,@assets/**,@scss/**,@images/**,@lottie/**}', group: 'internal', position: 'before' },
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export default [
|
|
118
|
+
{
|
|
119
|
+
name: 'codestyle/base',
|
|
120
|
+
plugins: { local: { rules: localRules } },
|
|
121
|
+
rules: {
|
|
122
|
+
// SFC block order and compiler macros at the top of script setup.
|
|
123
|
+
'vue/block-order': ['error', { order: ['script', 'template', 'style'] }],
|
|
124
|
+
'vue/define-macros-order': ['error', {
|
|
125
|
+
order: ['defineOptions', 'defineProps', 'defineEmits'],
|
|
126
|
+
defineExposeLast: true,
|
|
127
|
+
}],
|
|
128
|
+
'vue/match-component-file-name': ['error', { extensions: ['vue'], shouldMatchCase: true }],
|
|
129
|
+
// Event naming: kebab-case, `click:close`-style segments, Vue's own `update:modelValue`.
|
|
130
|
+
'vue/custom-event-name-casing': ['error', 'kebab-case', {
|
|
131
|
+
ignores: ['/^[a-z][a-z-]*(:[a-z][a-z-]*)+$/', '/^update:[a-zA-Z]+$/'],
|
|
132
|
+
}],
|
|
133
|
+
'vue/no-restricted-syntax': ['error', ...vueRestrictedSyntax],
|
|
134
|
+
'no-restricted-syntax': ['error', ...restrictedSyntax],
|
|
135
|
+
'no-restricted-imports': ['error', restrictedImports],
|
|
136
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
137
|
+
'local/padding-after-macros': 'error',
|
|
138
|
+
'local/async-components-after-macros': 'error',
|
|
139
|
+
'local/no-import-group-labels': 'error',
|
|
140
|
+
'local/ref-prefix-prop-mirrors': 'error',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'codestyle/composable-callbacks',
|
|
145
|
+
files: ['app/composables/**/*.ts'],
|
|
146
|
+
rules: {
|
|
147
|
+
'local/on-prefixed-callbacks': 'error',
|
|
148
|
+
// New rule — ships at warn until each app has measured it. See the package README.
|
|
149
|
+
'local/optional-callback-chaining': 'warn',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'codestyle/require-component-name',
|
|
154
|
+
files: ['app/components/**/*.vue'],
|
|
155
|
+
rules: { 'local/require-component-name': 'error' },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'codestyle/route-files',
|
|
159
|
+
// Nuxt page/layout files are kebab-case routes; their component names stay PascalCase by design.
|
|
160
|
+
files: ['app/pages/**/*.vue', 'app/layouts/**/*.vue', 'app/error.vue', 'app/app.vue'],
|
|
161
|
+
rules: { 'vue/match-component-file-name': 'off' },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'codestyle/types-organization-enums',
|
|
165
|
+
files: ['app/types/**/enums.ts'],
|
|
166
|
+
rules: { 'no-restricted-syntax': ['error', ...restrictedSyntax, ...typeFileSelectors['enums.ts']] },
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'codestyle/types-organization-interfaces',
|
|
170
|
+
files: ['app/types/**/interfaces.ts'],
|
|
171
|
+
rules: { 'no-restricted-syntax': ['error', ...restrictedSyntax, ...typeFileSelectors['interfaces.ts']] },
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'codestyle/types-organization-types',
|
|
175
|
+
files: ['app/types/**/types.ts'],
|
|
176
|
+
rules: { 'no-restricted-syntax': ['error', ...restrictedSyntax, ...typeFileSelectors['types.ts']] },
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'codestyle/file-naming',
|
|
180
|
+
files: ['app/**'],
|
|
181
|
+
plugins: { 'check-file': checkFile },
|
|
182
|
+
rules: {
|
|
183
|
+
'check-file/filename-naming-convention': ['error', checkFileFilenames, { ignoreMiddleExtensions: true }],
|
|
184
|
+
'check-file/folder-naming-convention': ['error', checkFileFolders],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const MACROS = new Set([
|
|
2
|
+
'defineOptions',
|
|
3
|
+
'defineProps',
|
|
4
|
+
'defineEmits',
|
|
5
|
+
'defineSlots',
|
|
6
|
+
'defineModel',
|
|
7
|
+
'defineExpose',
|
|
8
|
+
'withDefaults',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
function isMacroCall (node) {
|
|
12
|
+
return Boolean(node)
|
|
13
|
+
&& node.type === 'CallExpression'
|
|
14
|
+
&& node.callee.type === 'Identifier'
|
|
15
|
+
&& MACROS.has(node.callee.name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isAsyncComponentConst (stmt) {
|
|
19
|
+
return stmt.type === 'VariableDeclaration'
|
|
20
|
+
&& stmt.declarations.some(d =>
|
|
21
|
+
d.init
|
|
22
|
+
&& d.init.type === 'CallExpression'
|
|
23
|
+
&& d.init.callee.type === 'Identifier'
|
|
24
|
+
&& d.init.callee.name === 'defineAsyncComponent')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isHeadStatement (stmt) {
|
|
28
|
+
if (stmt.type === 'ImportDeclaration') return true
|
|
29
|
+
if (stmt.type === 'TSTypeAliasDeclaration' || stmt.type === 'TSInterfaceDeclaration') return true
|
|
30
|
+
if (stmt.type === 'ExpressionStatement') return isMacroCall(stmt.expression)
|
|
31
|
+
if (stmt.type === 'VariableDeclaration') return stmt.declarations.some(d => isMacroCall(d.init))
|
|
32
|
+
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
meta: {
|
|
38
|
+
type: 'suggestion',
|
|
39
|
+
docs: {
|
|
40
|
+
description: 'require defineAsyncComponent constants to sit directly after the compiler macros, before other logic',
|
|
41
|
+
},
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
tooLate: 'Declare async components right after the compiler macros, before other logic.',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
create (context) {
|
|
48
|
+
return {
|
|
49
|
+
Program (program) {
|
|
50
|
+
let codeZone = false
|
|
51
|
+
for (const stmt of program.body) {
|
|
52
|
+
if (isAsyncComponentConst(stmt)) {
|
|
53
|
+
if (codeZone) {
|
|
54
|
+
context.report({ node: stmt, messageId: 'tooLate' })
|
|
55
|
+
}
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (!isHeadStatement(stmt)) codeZone = true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const LABELS = new Set([
|
|
2
|
+
'types', 'type', 'interfaces', 'enums',
|
|
3
|
+
'stores', 'store', 'hooks', 'hook', 'helpers', 'helper', 'tools', 'tool',
|
|
4
|
+
'components', 'component', 'async components', 'assets', 'images', 'icons',
|
|
5
|
+
'config', 'configs', 'constants', 'utils', 'ws', 'api', 'auth',
|
|
6
|
+
'composables', 'libs', 'layouts', 'widgets', 'middleware', 'plugins', 'routes', 'typed',
|
|
7
|
+
'actions', 'getters', 'storage actions',
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
// Bare re-exports sit in the header zone with imports; `export const foo` is code.
|
|
11
|
+
function isHeaderStatement (node) {
|
|
12
|
+
switch (node.type) {
|
|
13
|
+
case 'ImportDeclaration':
|
|
14
|
+
case 'ExportAllDeclaration':
|
|
15
|
+
case 'TSTypeAliasDeclaration':
|
|
16
|
+
case 'TSInterfaceDeclaration':
|
|
17
|
+
return true
|
|
18
|
+
case 'ExportNamedDeclaration':
|
|
19
|
+
return Boolean(node.source) || !node.declaration
|
|
20
|
+
default:
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'suggestion',
|
|
28
|
+
fixable: 'code',
|
|
29
|
+
docs: {
|
|
30
|
+
description: 'ban import/export group label comments — grouping is enforced by import/order and blank lines',
|
|
31
|
+
},
|
|
32
|
+
schema: [],
|
|
33
|
+
messages: {
|
|
34
|
+
banned: 'Remove the "{{ label }}" group label — import/order enforces grouping, and fixers leave stale labels behind.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
create (context) {
|
|
38
|
+
const sourceCode = context.sourceCode
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
Program (program) {
|
|
42
|
+
const firstCode = program.body.find(node => !isHeaderStatement(node))
|
|
43
|
+
const boundary = firstCode ? firstCode.loc.start.line : Number.POSITIVE_INFINITY
|
|
44
|
+
|
|
45
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
46
|
+
if (comment.type !== 'Line') continue
|
|
47
|
+
if (comment.loc.start.line > boundary) continue
|
|
48
|
+
|
|
49
|
+
const label = comment.value.trim().toLowerCase()
|
|
50
|
+
if (!LABELS.has(label)) continue
|
|
51
|
+
|
|
52
|
+
// Only whole-line comments are labels; trailing ones annotate code.
|
|
53
|
+
const before = sourceCode.getTokenBefore(comment, { includeComments: true })
|
|
54
|
+
if (before && before.loc.end.line === comment.loc.start.line) continue
|
|
55
|
+
|
|
56
|
+
const start = sourceCode.getIndexFromLoc({ line: comment.loc.start.line, column: 0 })
|
|
57
|
+
const nextLine = comment.loc.end.line + 1
|
|
58
|
+
const end = nextLine <= sourceCode.lines.length
|
|
59
|
+
? sourceCode.getIndexFromLoc({ line: nextLine, column: 0 })
|
|
60
|
+
: comment.range[1]
|
|
61
|
+
|
|
62
|
+
context.report({
|
|
63
|
+
node: comment,
|
|
64
|
+
messageId: 'banned',
|
|
65
|
+
data: { label },
|
|
66
|
+
fix: fixer => fixer.removeRange([start, end]),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Composable option types are named `<Hook>Options` (legacy files still use `Use<Hook>Options`).
|
|
2
|
+
const OPTIONS_NAME = /^[A-Z]\w*Options$/
|
|
3
|
+
const ON_PREFIX = /^on[A-Z]/
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'suggestion',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'require on-prefixed PascalCase names for void callbacks in composable *Options types',
|
|
10
|
+
},
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
useOnPrefix: 'Callback "{{ name }}" in {{ type }} must be named "on" + PascalCase action (e.g. onSelect).',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
create (context) {
|
|
17
|
+
function checkMembers (typeName, members) {
|
|
18
|
+
for (const member of members) {
|
|
19
|
+
if (member.type !== 'TSPropertySignature' || member.computed) continue
|
|
20
|
+
const key = member.key
|
|
21
|
+
const name = key.type === 'Identifier' ? key.name : key.value
|
|
22
|
+
if (typeof name !== 'string') continue
|
|
23
|
+
|
|
24
|
+
// Only void callbacks replace emits; getters/providers (`() => number`, `() => Promise<T>`) are data.
|
|
25
|
+
|
|
26
|
+
const annotation = member.typeAnnotation?.typeAnnotation
|
|
27
|
+
|
|
28
|
+
const isVoidCallback = annotation?.type === 'TSFunctionType'
|
|
29
|
+
|
|
30
|
+
&& annotation.returnType?.typeAnnotation?.type === 'TSVoidKeyword'
|
|
31
|
+
|
|
32
|
+
if (isVoidCallback && !ON_PREFIX.test(name)) {
|
|
33
|
+
context.report({
|
|
34
|
+
node: key,
|
|
35
|
+
messageId: 'useOnPrefix',
|
|
36
|
+
data: { name, type: typeName },
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
TSInterfaceDeclaration (node) {
|
|
44
|
+
if (OPTIONS_NAME.test(node.id.name)) checkMembers(node.id.name, node.body.body)
|
|
45
|
+
},
|
|
46
|
+
TSTypeAliasDeclaration (node) {
|
|
47
|
+
if (OPTIONS_NAME.test(node.id.name) && node.typeAnnotation.type === 'TSTypeLiteral') {
|
|
48
|
+
checkMembers(node.id.name, node.typeAnnotation.members)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The half of the composable-callback convention that nothing enforced.
|
|
2
|
+
//
|
|
3
|
+
// An optional callback (`onFoo?: () => void`) that the composable invokes as `onFoo()`
|
|
4
|
+
// throws a TypeError for the first consumer that omits it. The convention says to call it
|
|
5
|
+
// with optional chaining — `onFoo?.()` — and until now only a reviewer could catch a miss.
|
|
6
|
+
//
|
|
7
|
+
// Scope: callbacks declared optional in a `*Options` type IN THE SAME FILE. Cross-file
|
|
8
|
+
// inference would need type information; this catches the case the convention describes,
|
|
9
|
+
// where the options type and the composable live together.
|
|
10
|
+
const OPTIONS_NAME = /^[A-Z]\w*Options$/
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description: 'require optional chaining when invoking an optional callback from a composable *Options type',
|
|
17
|
+
},
|
|
18
|
+
schema: [],
|
|
19
|
+
messages: {
|
|
20
|
+
useOptionalChaining: 'Callback "{{ name }}" is optional in {{ type }} — call it as {{ name }}?.() or it throws when a caller omits it.',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
create (context) {
|
|
24
|
+
/** name -> the *Options type that declared it optional */
|
|
25
|
+
const optionalCallbacks = new Map()
|
|
26
|
+
|
|
27
|
+
function collect (typeName, members) {
|
|
28
|
+
for (const member of members) {
|
|
29
|
+
if (member.type !== 'TSPropertySignature' || member.computed || !member.optional) continue
|
|
30
|
+
const key = member.key
|
|
31
|
+
const name = key.type === 'Identifier' ? key.name : key.value
|
|
32
|
+
if (typeof name !== 'string') continue
|
|
33
|
+
const annotation = member.typeAnnotation?.typeAnnotation
|
|
34
|
+
if (annotation?.type !== 'TSFunctionType') continue
|
|
35
|
+
optionalCallbacks.set(name, typeName)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
TSInterfaceDeclaration (node) {
|
|
41
|
+
if (OPTIONS_NAME.test(node.id.name)) collect(node.id.name, node.body.body)
|
|
42
|
+
},
|
|
43
|
+
TSTypeAliasDeclaration (node) {
|
|
44
|
+
if (OPTIONS_NAME.test(node.id.name) && node.typeAnnotation.type === 'TSTypeLiteral') {
|
|
45
|
+
collect(node.id.name, node.typeAnnotation.members)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
'Program:exit' (program) {
|
|
49
|
+
if (optionalCallbacks.size === 0) return
|
|
50
|
+
|
|
51
|
+
const source = context.sourceCode ?? context.getSourceCode()
|
|
52
|
+
|
|
53
|
+
// Walk every call; report the ones that name an optional callback without `?.`
|
|
54
|
+
const visit = (node) => {
|
|
55
|
+
if (!node || typeof node.type !== 'string') return
|
|
56
|
+
if (node.type === 'CallExpression'
|
|
57
|
+
&& node.callee?.type === 'Identifier'
|
|
58
|
+
&& optionalCallbacks.has(node.callee.name)
|
|
59
|
+
&& node.optional !== true) {
|
|
60
|
+
context.report({
|
|
61
|
+
node: node.callee,
|
|
62
|
+
messageId: 'useOptionalChaining',
|
|
63
|
+
data: { name: node.callee.name, type: optionalCallbacks.get(node.callee.name) },
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
for (const key of Object.keys(node)) {
|
|
67
|
+
if (key === 'parent') continue
|
|
68
|
+
const child = node[key]
|
|
69
|
+
if (Array.isArray(child)) child.forEach(visit)
|
|
70
|
+
else if (child && typeof child.type === 'string') visit(child)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
visit(program)
|
|
74
|
+
void source
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const MACROS = new Set([
|
|
2
|
+
'defineOptions',
|
|
3
|
+
'defineProps',
|
|
4
|
+
'defineEmits',
|
|
5
|
+
'defineSlots',
|
|
6
|
+
'defineModel',
|
|
7
|
+
'defineExpose',
|
|
8
|
+
'withDefaults',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
function isMacroCall (node) {
|
|
12
|
+
return Boolean(node)
|
|
13
|
+
&& node.type === 'CallExpression'
|
|
14
|
+
&& node.callee.type === 'Identifier'
|
|
15
|
+
&& MACROS.has(node.callee.name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function macroName (stmt) {
|
|
19
|
+
const call = stmt.type === 'ExpressionStatement'
|
|
20
|
+
? stmt.expression
|
|
21
|
+
: stmt.declarations.find(d => isMacroCall(d.init))?.init
|
|
22
|
+
|
|
23
|
+
return call?.callee?.name ?? 'macro'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isMacroStatement (stmt) {
|
|
27
|
+
if (stmt.type === 'ExpressionStatement') return isMacroCall(stmt.expression)
|
|
28
|
+
if (stmt.type === 'VariableDeclaration') return stmt.declarations.some(d => isMacroCall(d.init))
|
|
29
|
+
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default {
|
|
34
|
+
meta: {
|
|
35
|
+
type: 'layout',
|
|
36
|
+
fixable: 'whitespace',
|
|
37
|
+
docs: {
|
|
38
|
+
description: 'require an empty line after each compiler macro statement in <script setup>',
|
|
39
|
+
},
|
|
40
|
+
schema: [],
|
|
41
|
+
messages: {
|
|
42
|
+
expected: 'Expected an empty line after {{ name }}.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
create (context) {
|
|
46
|
+
const sourceCode = context.sourceCode
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
Program (program) {
|
|
50
|
+
const body = program.body
|
|
51
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
52
|
+
const stmt = body[i]
|
|
53
|
+
if (!isMacroStatement(stmt)) continue
|
|
54
|
+
|
|
55
|
+
const nextToken = sourceCode.getTokenAfter(stmt, { includeComments: true })
|
|
56
|
+
if (!nextToken) continue
|
|
57
|
+
|
|
58
|
+
if (nextToken.loc.start.line - stmt.loc.end.line < 2) {
|
|
59
|
+
context.report({
|
|
60
|
+
node: stmt,
|
|
61
|
+
messageId: 'expected',
|
|
62
|
+
data: { name: macroName(stmt) },
|
|
63
|
+
fix: fixer => fixer.insertTextAfter(stmt, '\n'),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function mirroredPropName (init) {
|
|
2
|
+
if (!init || init.type !== 'CallExpression' || init.callee.type !== 'Identifier') return null
|
|
3
|
+
|
|
4
|
+
if (init.callee.name === 'computed') {
|
|
5
|
+
const arg = init.arguments[0]
|
|
6
|
+
if (
|
|
7
|
+
arg
|
|
8
|
+
&& arg.type === 'ArrowFunctionExpression'
|
|
9
|
+
&& arg.body.type === 'MemberExpression'
|
|
10
|
+
&& arg.body.object.type === 'Identifier'
|
|
11
|
+
&& arg.body.object.name === 'props'
|
|
12
|
+
&& !arg.body.computed
|
|
13
|
+
) return arg.body.property.name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (init.callee.name === 'ref') {
|
|
17
|
+
const arg = init.arguments[0]
|
|
18
|
+
if (
|
|
19
|
+
arg
|
|
20
|
+
&& arg.type === 'MemberExpression'
|
|
21
|
+
&& arg.object.type === 'Identifier'
|
|
22
|
+
&& arg.object.name === 'props'
|
|
23
|
+
&& !arg.computed
|
|
24
|
+
) return arg.property.name
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default {
|
|
31
|
+
meta: {
|
|
32
|
+
type: 'suggestion',
|
|
33
|
+
docs: {
|
|
34
|
+
description: 'require the ref prefix on computed/ref constants that mirror a prop of the same name',
|
|
35
|
+
},
|
|
36
|
+
schema: [],
|
|
37
|
+
messages: {
|
|
38
|
+
useRefPrefix: 'Constant "{{ name }}" shadows the prop it mirrors — name it "{{ suggested }}".',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create (context) {
|
|
42
|
+
return {
|
|
43
|
+
VariableDeclarator (node) {
|
|
44
|
+
if (node.id.type !== 'Identifier') return
|
|
45
|
+
|
|
46
|
+
const prop = mirroredPropName(node.init)
|
|
47
|
+
if (prop && node.id.name === prop) {
|
|
48
|
+
const suggested = `ref${prop.charAt(0).toUpperCase()}${prop.slice(1)}`
|
|
49
|
+
context.report({
|
|
50
|
+
node: node.id,
|
|
51
|
+
messageId: 'useRefPrefix',
|
|
52
|
+
data: { name: node.id.name, suggested },
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|