@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.
Files changed (36) hide show
  1. package/README.md +224 -0
  2. package/dist/invariants/ast.d.ts +72 -0
  3. package/dist/invariants/ast.js +195 -0
  4. package/dist/invariants/index.d.ts +7 -0
  5. package/dist/invariants/index.js +5 -0
  6. package/dist/invariants/rules/convention.d.ts +18 -0
  7. package/dist/invariants/rules/convention.js +226 -0
  8. package/dist/invariants/rules/fixtures.d.ts +17 -0
  9. package/dist/invariants/rules/fixtures.js +65 -0
  10. package/dist/invariants/rules/index.d.ts +55 -0
  11. package/dist/invariants/rules/index.js +53 -0
  12. package/dist/invariants/rules/linter.d.ts +10 -0
  13. package/dist/invariants/rules/linter.js +9 -0
  14. package/dist/invariants/rules/regression.d.ts +13 -0
  15. package/dist/invariants/rules/regression.js +109 -0
  16. package/dist/invariants/rules/shared.d.ts +18 -0
  17. package/dist/invariants/rules/shared.js +25 -0
  18. package/dist/invariants/run.d.ts +56 -0
  19. package/dist/invariants/run.js +553 -0
  20. package/dist/invariants/self-test.d.ts +38 -0
  21. package/dist/invariants/self-test.js +57 -0
  22. package/dist/invariants/types.d.ts +192 -0
  23. package/dist/invariants/types.js +9 -0
  24. package/dist/invariants/util.d.ts +113 -0
  25. package/dist/invariants/util.js +269 -0
  26. package/dist/validate/index.d.ts +36 -0
  27. package/dist/validate/index.js +41 -0
  28. package/eslint/index.mjs +187 -0
  29. package/eslint/rules/async-components-after-macros.mjs +63 -0
  30. package/eslint/rules/no-import-group-labels.mjs +72 -0
  31. package/eslint/rules/on-prefixed-callbacks.mjs +53 -0
  32. package/eslint/rules/optional-callback-chaining.mjs +78 -0
  33. package/eslint/rules/padding-after-macros.mjs +70 -0
  34. package/eslint/rules/ref-prefix-prop-mirrors.mjs +58 -0
  35. package/eslint/rules/require-component-name.mjs +68 -0
  36. 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
+ };
@@ -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
+ }