@nitra/cursor 1.8.24 → 1.8.29
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/mdc/vue.mdc +2 -2
- package/package.json +2 -1
- package/scripts/check-ga.mjs +85 -23
- package/scripts/check-js-lint.mjs +31 -18
- package/scripts/check-npm-module.mjs +45 -12
- package/scripts/check-style-lint.mjs +5 -2
- package/scripts/check-text.mjs +4 -1
- package/scripts/check-vue.mjs +58 -2
- package/scripts/utils/gha-workflow.mjs +350 -0
- package/scripts/utils/vue-forbidden-imports.mjs +178 -0
package/mdc/vue.mdc
CHANGED
|
@@ -217,10 +217,10 @@ export default defineConfig({
|
|
|
217
217
|
|
|
218
218
|
Потрібно використовувати Vite версії 8 та вище для frontend проекту на Vue.
|
|
219
219
|
|
|
220
|
-
Потрібно використовувати unplugin-auto-import для автоматичного імпортування компонентів, composables, utils та інших функцій і прибирати з Vue
|
|
220
|
+
Потрібно використовувати unplugin-auto-import для автоматичного імпортування компонентів, composables, utils та інших функцій і прибирати з файлів усередині Vite проектів відповідні ручні імпорти, зокрема рядки виду `import { … } from 'vue'` — API Vue (`ref`, `computed`, `watch` тощо) мають підставлятися через auto-import, а не дублюватися явним імпортом з модуля `vue`.
|
|
221
221
|
|
|
222
222
|
Потрібно використовувати vite-plugin-vue-layouts-next для автоматичного імпортування layout компонентів.
|
|
223
223
|
|
|
224
224
|
## Перевірка
|
|
225
225
|
|
|
226
|
-
`npx @nitra/cursor check vue`
|
|
226
|
+
`npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, а також обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue`; дозволені лише type-only та side-effect `import 'vue'`. Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.29",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"test": "bun test tests"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"oxc-parser": "^0.124.0",
|
|
39
40
|
"yaml": "^2.8.3"
|
|
40
41
|
},
|
|
41
42
|
"engines": {
|
package/scripts/check-ga.mjs
CHANGED
|
@@ -7,13 +7,22 @@
|
|
|
7
7
|
* перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
|
|
8
8
|
*
|
|
9
9
|
* Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
|
|
10
|
-
* (oven-sh/setup-bun, actions/cache, bun install).
|
|
10
|
+
* (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
|
|
11
|
+
* (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
|
|
11
12
|
*/
|
|
12
13
|
import { existsSync } from 'node:fs'
|
|
13
14
|
import { readdir, readFile } from 'node:fs/promises'
|
|
14
15
|
import { join } from 'node:path'
|
|
15
16
|
|
|
16
17
|
import { pass } from './utils/pass.mjs'
|
|
18
|
+
import {
|
|
19
|
+
anyRunStepIncludes,
|
|
20
|
+
eventPathsIncludeExact,
|
|
21
|
+
findForbiddenUsesOrRunPatterns,
|
|
22
|
+
hasAnyStepUsesContaining,
|
|
23
|
+
hasCheckoutBeforeLocalSetupBunDeps,
|
|
24
|
+
parseWorkflowYaml
|
|
25
|
+
} from './utils/gha-workflow.mjs'
|
|
17
26
|
|
|
18
27
|
/** Шаблони наявності MegaLinter у вмісті workflow */
|
|
19
28
|
const MEGALINTER_USE_PATTERNS = [/oxsecurity\/megalinter-action/i, /megalinter\/megalinter/i]
|
|
@@ -21,8 +30,19 @@ const MEGALINTER_USE_PATTERNS = [/oxsecurity\/megalinter-action/i, /megalinter\/
|
|
|
21
30
|
/** Типові конфіги MegaLinter у корені репо */
|
|
22
31
|
const MEGALINTER_CONFIG_NAMES = ['.mega-linter.yml', '.megalinter.yaml', '.mega-linter.yaml']
|
|
23
32
|
|
|
33
|
+
/** Локальні composite setup-bun-deps (ga.mdc). */
|
|
34
|
+
const SETUP_BUN_PATTERNS = ['./.github/actions/setup-bun-deps', './npm/github-actions/setup-bun-deps']
|
|
35
|
+
|
|
36
|
+
/** Заборонені підрядки лише в кроках uses/run. */
|
|
37
|
+
const FORBIDDEN_BUN_PATTERNS = [
|
|
38
|
+
{ pattern: 'oven-sh/setup-bun', msg: 'використовуй .github/actions/setup-bun-deps замість oven-sh/setup-bun' },
|
|
39
|
+
{ pattern: 'actions/cache', msg: 'використовуй .github/actions/setup-bun-deps замість actions/cache' },
|
|
40
|
+
{ pattern: 'bun install', msg: 'використовуй .github/actions/setup-bun-deps замість bun install' }
|
|
41
|
+
]
|
|
42
|
+
|
|
24
43
|
/**
|
|
25
44
|
* Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
|
|
45
|
+
* Fallback: сирий текст, якщо YAML не вдається розібрати.
|
|
26
46
|
* @param {string} relPath шлях для повідомлень
|
|
27
47
|
* @param {string} content вміст YAML
|
|
28
48
|
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
@@ -30,9 +50,22 @@ const MEGALINTER_CONFIG_NAMES = ['.mega-linter.yml', '.megalinter.yaml', '.mega-
|
|
|
30
50
|
* @returns {void}
|
|
31
51
|
*/
|
|
32
52
|
function verifyCheckoutBeforeLocalSetupBunDeps(relPath, content, failFn, passFn) {
|
|
33
|
-
const
|
|
53
|
+
const root = parseWorkflowYaml(content)
|
|
54
|
+
if (root) {
|
|
55
|
+
if (!hasAnyStepUsesContaining(root, SETUP_BUN_PATTERNS)) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (!hasCheckoutBeforeLocalSetupBunDeps(root, SETUP_BUN_PATTERNS)) {
|
|
59
|
+
failFn(
|
|
60
|
+
`${relPath}: перед локальним setup-bun-deps потрібен крок actions/checkout@v6 — інакше runner не знайде action.yml (ga.mdc)`
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
passFn(`${relPath}: перед setup-bun-deps є checkout`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
34
67
|
let idxSetup = -1
|
|
35
|
-
for (const p of
|
|
68
|
+
for (const p of SETUP_BUN_PATTERNS) {
|
|
36
69
|
const i = content.indexOf(p)
|
|
37
70
|
if (i !== -1 && (idxSetup === -1 || i < idxSetup)) {
|
|
38
71
|
idxSetup = i
|
|
@@ -52,27 +85,33 @@ function verifyCheckoutBeforeLocalSetupBunDeps(relPath, content, failFn, passFn)
|
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
/**
|
|
55
|
-
*
|
|
88
|
+
* Перевіряє заборонені кроки Bun/cache/install у `uses` та `run`.
|
|
56
89
|
* @param {string} relPath шлях для повідомлень
|
|
57
90
|
* @param {string} content вміст YAML
|
|
58
91
|
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
59
92
|
* @param {(msg: string) => void} passFn реєструє успішну перевірку
|
|
93
|
+
* @returns {void}
|
|
60
94
|
*/
|
|
61
95
|
function verifyNoDirectBunOrCache(relPath, content, failFn, passFn) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
const root = parseWorkflowYaml(content)
|
|
97
|
+
if (root) {
|
|
98
|
+
const hits = findForbiddenUsesOrRunPatterns(root, FORBIDDEN_BUN_PATTERNS)
|
|
99
|
+
if (hits.length === 0) {
|
|
100
|
+
passFn(`${relPath}: не містить заборонених кроків setup-bun/cache/install`)
|
|
101
|
+
} else {
|
|
102
|
+
for (const h of hits) {
|
|
103
|
+
failFn(`${relPath}: ${h.msg} (ga.mdc)`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return
|
|
107
|
+
}
|
|
68
108
|
let foundForbidden = false
|
|
69
|
-
for (const { pattern, msg } of
|
|
109
|
+
for (const { pattern, msg } of FORBIDDEN_BUN_PATTERNS) {
|
|
70
110
|
if (content.includes(pattern)) {
|
|
71
111
|
failFn(`${relPath}: ${msg} (ga.mdc)`)
|
|
72
112
|
foundForbidden = true
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
|
-
|
|
76
115
|
if (!foundForbidden) {
|
|
77
116
|
passFn(`${relPath}: не містить заборонених кроків setup-bun/cache/install`)
|
|
78
117
|
}
|
|
@@ -109,7 +148,9 @@ export async function check() {
|
|
|
109
148
|
|
|
110
149
|
const yamlFiles = files.filter(f => f.endsWith('.yaml'))
|
|
111
150
|
if (yamlFiles.length > 0) {
|
|
112
|
-
for (const f of yamlFiles)
|
|
151
|
+
for (const f of yamlFiles) {
|
|
152
|
+
fail(`Workflow з розширенням .yaml: ${wfDir}/${f} — перейменуй на .yml`)
|
|
153
|
+
}
|
|
113
154
|
} else {
|
|
114
155
|
pass('Всі workflows мають розширення .yml')
|
|
115
156
|
}
|
|
@@ -124,7 +165,10 @@ export async function check() {
|
|
|
124
165
|
|
|
125
166
|
if (files.includes('apply-k8s.yml')) {
|
|
126
167
|
const content = await readFile(`${wfDir}/apply-k8s.yml`, 'utf8')
|
|
127
|
-
|
|
168
|
+
const root = parseWorkflowYaml(content)
|
|
169
|
+
const ok =
|
|
170
|
+
root && eventPathsIncludeExact(root, 'push', '**/k8s/**/*.yaml') ? true : content.includes('**/k8s/**/*.yaml')
|
|
171
|
+
if (ok) {
|
|
128
172
|
pass('apply-k8s.yml має правильний paths trigger')
|
|
129
173
|
} else {
|
|
130
174
|
fail('apply-k8s.yml не містить paths: **/k8s/**/*.yaml')
|
|
@@ -133,7 +177,10 @@ export async function check() {
|
|
|
133
177
|
|
|
134
178
|
if (files.includes('apply-nats-consumer.yml')) {
|
|
135
179
|
const content = await readFile(`${wfDir}/apply-nats-consumer.yml`, 'utf8')
|
|
136
|
-
|
|
180
|
+
const root = parseWorkflowYaml(content)
|
|
181
|
+
const ok =
|
|
182
|
+
root && eventPathsIncludeExact(root, 'push', '**/consumer.yaml') ? true : content.includes('**/consumer.yaml')
|
|
183
|
+
if (ok) {
|
|
137
184
|
pass('apply-nats-consumer.yml має правильний paths trigger')
|
|
138
185
|
} else {
|
|
139
186
|
fail('apply-nats-consumer.yml не містить paths: **/consumer.yaml')
|
|
@@ -216,15 +263,30 @@ export async function check() {
|
|
|
216
263
|
const lintGaWf = join(wfDir, 'lint-ga.yml')
|
|
217
264
|
if (existsSync(lintGaWf)) {
|
|
218
265
|
const lgContent = await readFile(lintGaWf, 'utf8')
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
266
|
+
const root = parseWorkflowYaml(lgContent)
|
|
267
|
+
if (root) {
|
|
268
|
+
if (anyRunStepIncludes(root, 'bun run lint-ga')) {
|
|
269
|
+
pass('lint-ga.yml викликає bun run lint-ga')
|
|
270
|
+
} else {
|
|
271
|
+
fail('lint-ga.yml: крок має містити bun run lint-ga')
|
|
272
|
+
}
|
|
273
|
+
const usesFlat = hasAnyStepUsesContaining(root, ['astral-sh/setup-uv'])
|
|
274
|
+
if (usesFlat || lgContent.includes('astral-sh/setup-uv')) {
|
|
275
|
+
pass('lint-ga.yml містить astral-sh/setup-uv')
|
|
276
|
+
} else {
|
|
277
|
+
fail('lint-ga.yml: додай astral-sh/setup-uv для uvx zizmor (ga.mdc)')
|
|
278
|
+
}
|
|
226
279
|
} else {
|
|
227
|
-
|
|
280
|
+
if (lgContent.includes('bun run lint-ga')) {
|
|
281
|
+
pass('lint-ga.yml викликає bun run lint-ga')
|
|
282
|
+
} else {
|
|
283
|
+
fail('lint-ga.yml: крок має містити bun run lint-ga')
|
|
284
|
+
}
|
|
285
|
+
if (lgContent.includes('astral-sh/setup-uv')) {
|
|
286
|
+
pass('lint-ga.yml містить astral-sh/setup-uv')
|
|
287
|
+
} else {
|
|
288
|
+
fail('lint-ga.yml: додай astral-sh/setup-uv для uvx zizmor (ga.mdc)')
|
|
289
|
+
}
|
|
228
290
|
}
|
|
229
291
|
}
|
|
230
292
|
|
|
@@ -10,6 +10,7 @@ import { existsSync } from 'node:fs'
|
|
|
10
10
|
import { readFile } from 'node:fs/promises'
|
|
11
11
|
|
|
12
12
|
import { pass } from './utils/pass.mjs'
|
|
13
|
+
import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-workflow.mjs'
|
|
13
14
|
|
|
14
15
|
/** Очікуваний локальний скрипт (oxlint без bunx; eslint/jscpd через bunx). */
|
|
15
16
|
export const CANONICAL_LINT_JS = 'oxlint --fix && bunx eslint --fix . && bunx jscpd .'
|
|
@@ -154,26 +155,38 @@ export async function check() {
|
|
|
154
155
|
if (existsSync('.github/workflows/lint-js.yml')) {
|
|
155
156
|
const content = await readFile('.github/workflows/lint-js.yml', 'utf8')
|
|
156
157
|
pass('lint-js.yml існує')
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
['bunx eslint .', 'lint-js.yml: у run має бути bunx eslint . (без --fix у CI)'],
|
|
163
|
-
['bunx jscpd .', 'lint-js.yml: у run має бути bunx jscpd .']
|
|
164
|
-
]
|
|
165
|
-
for (const [needle, errMsg] of checks) {
|
|
166
|
-
if (content.includes(needle)) {
|
|
167
|
-
pass(`lint-js.yml містить: ${needle}`)
|
|
158
|
+
const root = parseWorkflowYaml(content)
|
|
159
|
+
if (root) {
|
|
160
|
+
const v = verifyLintJsWorkflowStructure(root)
|
|
161
|
+
if (v.ok) {
|
|
162
|
+
pass('lint-js.yml: кроки checkout, setup-bun-deps, oxlint/eslint/jscpd (YAML + кроки)')
|
|
168
163
|
} else {
|
|
169
|
-
|
|
164
|
+
for (const msg of v.failures) {
|
|
165
|
+
fail(`lint-js.yml: ${msg}`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
const checks = [
|
|
170
|
+
['actions/checkout@v6', 'lint-js.yml: потрібен крок actions/checkout@v6 (ga.mdc)'],
|
|
171
|
+
['persist-credentials: false', 'lint-js.yml: checkout з persist-credentials: false'],
|
|
172
|
+
['./.github/actions/setup-bun-deps', 'lint-js.yml: потрібен uses: ./.github/actions/setup-bun-deps'],
|
|
173
|
+
['bunx oxlint', 'lint-js.yml: у run має бути bunx oxlint'],
|
|
174
|
+
['bunx eslint .', 'lint-js.yml: у run має бути bunx eslint . (без --fix у CI)'],
|
|
175
|
+
['bunx jscpd .', 'lint-js.yml: у run має бути bunx jscpd .']
|
|
176
|
+
]
|
|
177
|
+
for (const [needle, errMsg] of checks) {
|
|
178
|
+
if (content.includes(needle)) {
|
|
179
|
+
pass(`lint-js.yml містить: ${needle}`)
|
|
180
|
+
} else {
|
|
181
|
+
fail(errMsg)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
|
|
185
|
+
fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
|
|
186
|
+
}
|
|
187
|
+
if (content.includes('eslint --fix')) {
|
|
188
|
+
fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
|
|
170
189
|
}
|
|
171
|
-
}
|
|
172
|
-
if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
|
|
173
|
-
fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
|
|
174
|
-
}
|
|
175
|
-
if (content.includes('eslint --fix')) {
|
|
176
|
-
fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
|
|
177
190
|
}
|
|
178
191
|
} else {
|
|
179
192
|
fail('.github/workflows/lint-js.yml не існує — створи його (див. check-js-lint.mjs / js-lint.mdc)')
|
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
* Перевіряє структуру npm-модуля в монорепо за правилом npm-module.mdc.
|
|
3
3
|
*
|
|
4
4
|
* Workspace `npm/`, `npm/package.json`, workflow `npm-publish.yml` з OIDC, `on.push.paths` з glob для каталогу npm (див. npm-module.mdc).
|
|
5
|
+
* Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync } from 'node:fs'
|
|
7
8
|
import { readFile, stat } from 'node:fs/promises'
|
|
8
9
|
|
|
9
10
|
import { pass } from './utils/pass.mjs'
|
|
11
|
+
import {
|
|
12
|
+
hasIdTokenWritePermission,
|
|
13
|
+
hasNpmPublishStepWithPackage,
|
|
14
|
+
parseWorkflowYaml,
|
|
15
|
+
pushHasMainBranch,
|
|
16
|
+
pushPathsIncludeNpmGlob
|
|
17
|
+
} from './utils/gha-workflow.mjs'
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Перевіряє відповідність проєкту правилам npm-module.mdc
|
|
@@ -62,19 +70,44 @@ export async function check() {
|
|
|
62
70
|
if (existsSync(publishWf)) {
|
|
63
71
|
pass(`${publishWf} існує`)
|
|
64
72
|
const pub = await readFile(publishWf, 'utf8')
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
const root = parseWorkflowYaml(pub)
|
|
74
|
+
|
|
75
|
+
if (root) {
|
|
76
|
+
if (pushPathsIncludeNpmGlob(root)) {
|
|
77
|
+
pass(`${publishWf}: on.push.paths містить npm/**`)
|
|
78
|
+
} else {
|
|
79
|
+
fail(`${publishWf}: у on.push.paths має бути npm/**`)
|
|
80
|
+
}
|
|
81
|
+
if (pushHasMainBranch(root)) {
|
|
82
|
+
pass(`${publishWf}: очікується branch main`)
|
|
83
|
+
} else {
|
|
84
|
+
fail(`${publishWf}: очікується branch main`)
|
|
85
|
+
}
|
|
86
|
+
if (hasIdTokenWritePermission(root)) {
|
|
87
|
+
pass(`${publishWf}: permissions містить id-token: write (OIDC)`)
|
|
76
88
|
} else {
|
|
77
|
-
fail(
|
|
89
|
+
fail(`${publishWf}: permissions має містити id-token: write (OIDC)`)
|
|
90
|
+
}
|
|
91
|
+
if (hasNpmPublishStepWithPackage(root)) {
|
|
92
|
+
pass(`${publishWf}: uses JS-DevTools/npm-publish та with.package npm/package.json`)
|
|
93
|
+
} else {
|
|
94
|
+
fail(`${publishWf}: очікується uses: JS-DevTools/npm-publish та with.package: npm/package.json`)
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const need = [
|
|
98
|
+
{ sub: 'npm/**', msg: `${publishWf}: у on.push.paths має бути npm/**` },
|
|
99
|
+
{ sub: 'branches:', msg: `${publishWf}: очікується on.push.branches` },
|
|
100
|
+
{ sub: 'main', msg: `${publishWf}: очікується branch main` },
|
|
101
|
+
{ sub: 'id-token: write', msg: `${publishWf}: permissions має містити id-token: write (OIDC)` },
|
|
102
|
+
{ sub: 'JS-DevTools/npm-publish', msg: `${publishWf}: очікується uses: JS-DevTools/npm-publish` },
|
|
103
|
+
{ sub: 'package: npm/package.json', msg: `${publishWf}: with.package має бути npm/package.json` }
|
|
104
|
+
]
|
|
105
|
+
for (const { sub, msg } of need) {
|
|
106
|
+
if (pub.includes(sub)) {
|
|
107
|
+
pass(`${publishWf} містить «${sub}»`)
|
|
108
|
+
} else {
|
|
109
|
+
fail(msg)
|
|
110
|
+
}
|
|
78
111
|
}
|
|
79
112
|
}
|
|
80
113
|
} else {
|
|
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs'
|
|
|
8
8
|
import { readFile } from 'node:fs/promises'
|
|
9
9
|
|
|
10
10
|
import { pass } from './utils/pass.mjs'
|
|
11
|
+
import { anyRunStepIncludesStylelint, parseWorkflowYaml } from './utils/gha-workflow.mjs'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Перевіряє відповідність проєкту правилам style-lint.mdc
|
|
@@ -54,8 +55,10 @@ export async function check() {
|
|
|
54
55
|
if (existsSync('.github/workflows/lint-style.yml')) {
|
|
55
56
|
const content = await readFile('.github/workflows/lint-style.yml', 'utf8')
|
|
56
57
|
pass('lint-style.yml існує')
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
const root = parseWorkflowYaml(content)
|
|
59
|
+
const ok = root ? anyRunStepIncludesStylelint(root) : content.includes('stylelint')
|
|
60
|
+
if (ok) {
|
|
61
|
+
pass('lint-style.yml містить stylelint у кроці run')
|
|
59
62
|
} else {
|
|
60
63
|
fail('lint-style.yml не містить виклик stylelint')
|
|
61
64
|
}
|
package/scripts/check-text.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync } from 'node:fs'
|
|
|
13
13
|
import { readFile } from 'node:fs/promises'
|
|
14
14
|
|
|
15
15
|
import { pass } from './utils/pass.mjs'
|
|
16
|
+
import { anyRunStepIncludes, parseWorkflowYaml } from './utils/gha-workflow.mjs'
|
|
16
17
|
|
|
17
18
|
/** Заголовок абзацу про апостроф у text.mdc / n-text.mdc. */
|
|
18
19
|
const UK_APOSTROPHE_HEADING = '**Український апостроф:**'
|
|
@@ -194,7 +195,9 @@ export async function check() {
|
|
|
194
195
|
|
|
195
196
|
if (existsSync('.github/workflows/lint-text.yml')) {
|
|
196
197
|
const wf = await readFile('.github/workflows/lint-text.yml', 'utf8')
|
|
197
|
-
|
|
198
|
+
const root = parseWorkflowYaml(wf)
|
|
199
|
+
const ok = root ? anyRunStepIncludes(root, 'bun run lint-text') : wf.includes('bun run lint-text')
|
|
200
|
+
if (ok) {
|
|
198
201
|
pass('lint-text.yml викликає bun run lint-text')
|
|
199
202
|
} else {
|
|
200
203
|
fail('lint-text.yml має містити крок bun run lint-text')
|
package/scripts/check-vue.mjs
CHANGED
|
@@ -3,12 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Версії Vite та плагінів, vue-macros, auto-import, layouts, вміст `vite.config`;
|
|
5
5
|
* у репозиторії — рекомендацію розширення Vue.volar.
|
|
6
|
+
*
|
|
7
|
+
* Заборонені явні value-імпорти з `vue` у джерелах пакета — сканування `.vue`/`.ts`/`.js` тощо
|
|
8
|
+
* через **oxc-parser** (`module.staticImports`; див. `utils/vue-forbidden-imports.mjs`); дозволені лише type-only та side-effect `import 'vue'`.
|
|
6
9
|
*/
|
|
7
10
|
import { existsSync } from 'node:fs'
|
|
8
11
|
import { readFile } from 'node:fs/promises'
|
|
9
|
-
import { join } from 'node:path'
|
|
12
|
+
import { join, relative } from 'node:path'
|
|
10
13
|
|
|
11
14
|
import { pass } from './utils/pass.mjs'
|
|
15
|
+
import {
|
|
16
|
+
findForbiddenVueImportsInSourceFile,
|
|
17
|
+
isVueImportScanSourceFile,
|
|
18
|
+
shouldSkipFileForVueImportScan
|
|
19
|
+
} from './utils/vue-forbidden-imports.mjs'
|
|
20
|
+
import { walkDir } from './utils/walkDir.mjs'
|
|
12
21
|
import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
13
22
|
|
|
14
23
|
/**
|
|
@@ -20,11 +29,31 @@ function packageLabel(rootDir) {
|
|
|
20
29
|
return rootDir === '.' ? 'корінь' : rootDir
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Текст кількості файлів українською (1 файл, 2 файли, 5 файлів, 11 файлів).
|
|
34
|
+
* @param {number} n невід’ємна кількість
|
|
35
|
+
* @returns {string} фраза виду «N файл» / «N файли» / «N файлів»
|
|
36
|
+
*/
|
|
37
|
+
function ukFilesCountPhrase(n) {
|
|
38
|
+
const m100 = n % 100
|
|
39
|
+
if (m100 >= 11 && m100 <= 14) {
|
|
40
|
+
return `${n} файлів`
|
|
41
|
+
}
|
|
42
|
+
const m10 = n % 10
|
|
43
|
+
if (m10 === 1) {
|
|
44
|
+
return `${n} файл`
|
|
45
|
+
}
|
|
46
|
+
if (m10 >= 2 && m10 <= 4) {
|
|
47
|
+
return `${n} файли`
|
|
48
|
+
}
|
|
49
|
+
return `${n} файлів`
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
/**
|
|
24
53
|
* Перевіряє залежності та vite.config одного Vue-пакета.
|
|
25
54
|
* @param {string} rootDir відносний шлях до пакета
|
|
26
55
|
* @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
|
|
27
|
-
* @returns {Promise<void>} завершується після перевірок
|
|
56
|
+
* @returns {Promise<void>} завершується після перевірок залежностей, `vite.config` і сканування джерел на імпорти з `vue`
|
|
28
57
|
*/
|
|
29
58
|
async function checkVuePackage(rootDir, fail) {
|
|
30
59
|
const label = packageLabel(rootDir)
|
|
@@ -95,6 +124,33 @@ async function checkVuePackage(rootDir, fail) {
|
|
|
95
124
|
} else {
|
|
96
125
|
fail(`${prefix}немає vite.config.js|ts|mjs у каталозі пакета`)
|
|
97
126
|
}
|
|
127
|
+
|
|
128
|
+
const absPackageRoot = join(process.cwd(), rootDir)
|
|
129
|
+
/** @type {string[]} */
|
|
130
|
+
const sourcePaths = []
|
|
131
|
+
await walkDir(absPackageRoot, absPath => {
|
|
132
|
+
const rel = relative(absPackageRoot, absPath).split('\\').join('/')
|
|
133
|
+
if (shouldSkipFileForVueImportScan(rel) || !isVueImportScanSourceFile(rel)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
sourcePaths.push(absPath)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
let importViolations = 0
|
|
140
|
+
for (const absPath of sourcePaths) {
|
|
141
|
+
const rel = relative(absPackageRoot, absPath).split('\\').join('/')
|
|
142
|
+
const content = await readFile(absPath, 'utf8')
|
|
143
|
+
const hits = findForbiddenVueImportsInSourceFile(content, rel)
|
|
144
|
+
for (const v of hits) {
|
|
145
|
+
importViolations++
|
|
146
|
+
fail(`${prefix}${rel}:${v.line} — прибери явний value-імпорт з 'vue' (unplugin-auto-import): ${v.snippet}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (importViolations === 0) {
|
|
150
|
+
pass(
|
|
151
|
+
`${prefix}немає заборонених value-імпортів з 'vue' у джерелах (проскановано ${ukFilesCountPhrase(sourcePaths.length)})`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
98
154
|
}
|
|
99
155
|
|
|
100
156
|
/**
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Допоміжні функції для аналізу GitHub Actions workflow (`.yml`) після структурного розбору YAML.
|
|
3
|
+
*
|
|
4
|
+
* Використовується в check-ga, check-js-lint, check-text, check-style-lint, check-npm-module замість
|
|
5
|
+
* пошуку підрядків у сирому тексті там, де важливі лише значення `uses:` та `run:` кроків.
|
|
6
|
+
*/
|
|
7
|
+
import { parse } from 'yaml'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Парсить workflow YAML у звичайний об’єкт; при синтаксичній помилці — `null`.
|
|
11
|
+
* @param {string} content вміст файлу
|
|
12
|
+
* @returns {Record<string, unknown> | null} корінь документа або `null`
|
|
13
|
+
*/
|
|
14
|
+
export function parseWorkflowYaml(content) {
|
|
15
|
+
try {
|
|
16
|
+
const root = parse(content)
|
|
17
|
+
return root && typeof root === 'object' ? /** @type {Record<string, unknown>} */ (root) : null
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Збирає всі кроки з усіх jobs.
|
|
25
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
26
|
+
* @returns {{ jobId: string, stepIndex: number, step: Record<string, unknown> }[]} плоский список кроків з метаданими
|
|
27
|
+
*/
|
|
28
|
+
export function flattenWorkflowSteps(root) {
|
|
29
|
+
const jobs = root?.jobs
|
|
30
|
+
if (!jobs || typeof jobs !== 'object') {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
/** @type {{ jobId: string, stepIndex: number, step: Record<string, unknown> }[]} */
|
|
34
|
+
const out = []
|
|
35
|
+
for (const [jobId, job] of Object.entries(jobs)) {
|
|
36
|
+
if (job && typeof job === 'object') {
|
|
37
|
+
const steps = /** @type {{ steps?: unknown }} */ (job).steps
|
|
38
|
+
if (Array.isArray(steps)) {
|
|
39
|
+
for (const [stepIndex, step] of steps.entries()) {
|
|
40
|
+
if (step && typeof step === 'object') {
|
|
41
|
+
out.push({
|
|
42
|
+
jobId,
|
|
43
|
+
stepIndex,
|
|
44
|
+
step: /** @type {Record<string, unknown>} */ (step)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Значення `uses:` кроку.
|
|
56
|
+
* @param {Record<string, unknown>} step об’єкт одного елемента `steps`
|
|
57
|
+
* @returns {string} рядок `uses` або порожній рядок
|
|
58
|
+
*/
|
|
59
|
+
export function getStepUses(step) {
|
|
60
|
+
return typeof step.uses === 'string' ? step.uses : ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Значення `run:` кроку (багаторядковий рядок або масив рядків у YAML).
|
|
65
|
+
* @param {Record<string, unknown>} step об’єкт одного елемента `steps`
|
|
66
|
+
* @returns {string} текст команди
|
|
67
|
+
*/
|
|
68
|
+
export function getStepRun(step) {
|
|
69
|
+
const r = step.run
|
|
70
|
+
if (typeof r === 'string') {
|
|
71
|
+
return r
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(r)) {
|
|
74
|
+
return r.map(String).join('\n')
|
|
75
|
+
}
|
|
76
|
+
return ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Чи є крок, у якого `uses` містить будь-який з підрядків.
|
|
81
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
82
|
+
* @param {string[]} substrings підрядки для пошуку в `uses`
|
|
83
|
+
* @returns {boolean} `true`, якщо знайдено хоча б один збіг
|
|
84
|
+
*/
|
|
85
|
+
export function hasAnyStepUsesContaining(root, substrings) {
|
|
86
|
+
for (const { step } of flattenWorkflowSteps(root)) {
|
|
87
|
+
const uses = getStepUses(step)
|
|
88
|
+
if (substrings.some(s => uses.includes(s))) {
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Чи перед першим кроком з локальним `setup-bun-deps` у кожному job є `actions/checkout@`.
|
|
97
|
+
* Якщо `setup-bun-deps` у файлі немає — `true`.
|
|
98
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
99
|
+
* @param {string[]} setupPathSubstrings підрядки `uses`, що означають локальний composite (наприклад `./.github/actions/setup-bun-deps`)
|
|
100
|
+
* @returns {boolean} `false`, якщо є setup без попереднього checkout
|
|
101
|
+
*/
|
|
102
|
+
export function hasCheckoutBeforeLocalSetupBunDeps(root, setupPathSubstrings) {
|
|
103
|
+
const jobs = root?.jobs
|
|
104
|
+
if (!jobs || typeof jobs !== 'object') {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
for (const job of Object.values(jobs)) {
|
|
108
|
+
if (job && typeof job === 'object') {
|
|
109
|
+
const steps = /** @type {{ steps?: unknown }} */ (job).steps
|
|
110
|
+
if (Array.isArray(steps)) {
|
|
111
|
+
for (let i = 0; i < steps.length; i++) {
|
|
112
|
+
const step = steps[i]
|
|
113
|
+
if (step && typeof step === 'object') {
|
|
114
|
+
const uses = getStepUses(/** @type {Record<string, unknown>} */ (step))
|
|
115
|
+
const isSetup = setupPathSubstrings.some(s => uses.includes(s))
|
|
116
|
+
if (isSetup) {
|
|
117
|
+
let foundCheckout = false
|
|
118
|
+
for (let j = 0; j < i; j++) {
|
|
119
|
+
const prev = steps[j]
|
|
120
|
+
if (prev && typeof prev === 'object') {
|
|
121
|
+
const u = getStepUses(/** @type {Record<string, unknown>} */ (prev))
|
|
122
|
+
if (u.includes('actions/checkout@')) {
|
|
123
|
+
foundCheckout = true
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!foundCheckout) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Шукає заборонені підрядки лише в `uses` та `run` кроків (не в коментарях YAML поза кроками).
|
|
142
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
143
|
+
* @param {{ pattern: string, msg: string }[]} forbidden список заборонених фрагментів і повідомлень
|
|
144
|
+
* @returns {{ jobId: string, stepIndex: number, pattern: string, msg: string }[]} знайдені збіги
|
|
145
|
+
*/
|
|
146
|
+
export function findForbiddenUsesOrRunPatterns(root, forbidden) {
|
|
147
|
+
/** @type {{ jobId: string, stepIndex: number, pattern: string, msg: string }[]} */
|
|
148
|
+
const hits = []
|
|
149
|
+
for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
|
|
150
|
+
const uses = getStepUses(step)
|
|
151
|
+
const run = getStepRun(step)
|
|
152
|
+
const blob = `${uses}\n${run}`
|
|
153
|
+
for (const { pattern, msg } of forbidden) {
|
|
154
|
+
if (blob.includes(pattern)) {
|
|
155
|
+
hits.push({ jobId, stepIndex, pattern, msg })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return hits
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Чи є в `on.push.paths` (або `on.pull_request.paths`) елемент з точним значенням.
|
|
164
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
165
|
+
* @param {'push' | 'pull_request'} event ім’я ключа в `on`
|
|
166
|
+
* @param {string} exact очікуваний glob
|
|
167
|
+
* @returns {boolean} `true`, якщо шлях присутній у масиві `paths`
|
|
168
|
+
*/
|
|
169
|
+
export function eventPathsIncludeExact(root, event, exact) {
|
|
170
|
+
const on = root?.on
|
|
171
|
+
if (!on || typeof on !== 'object') {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
const ev = /** @type {Record<string, unknown>} */ (on)[event]
|
|
175
|
+
if (!ev || typeof ev !== 'object') {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
const paths = /** @type {Record<string, unknown>} */ (ev).paths
|
|
179
|
+
if (!Array.isArray(paths)) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
return paths.includes(exact)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Чи містить `on.push.paths` підрядок `npm/**` (npm-module).
|
|
187
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
188
|
+
* @returns {boolean} `true`, якщо серед `paths` є рядок з `npm/**`
|
|
189
|
+
*/
|
|
190
|
+
export function pushPathsIncludeNpmGlob(root) {
|
|
191
|
+
const on = root?.on
|
|
192
|
+
if (!on || typeof on !== 'object') {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
const push = /** @type {Record<string, unknown>} */ (on).push
|
|
196
|
+
if (!push || typeof push !== 'object') {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
const paths = push.paths
|
|
200
|
+
if (!Array.isArray(paths)) {
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
return paths.some(p => typeof p === 'string' && p.includes('npm/**'))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Перевіряє наявність `branches` з `main` у `on.push`.
|
|
208
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
209
|
+
* @returns {boolean} `true`, якщо `main` є в `on.push.branches`
|
|
210
|
+
*/
|
|
211
|
+
export function pushHasMainBranch(root) {
|
|
212
|
+
const on = root?.on
|
|
213
|
+
if (!on || typeof on !== 'object') {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
const push = /** @type {Record<string, unknown>} */ (on).push
|
|
217
|
+
if (!push || typeof push !== 'object') {
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
const branches = push.branches
|
|
221
|
+
if (!Array.isArray(branches)) {
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
return branches.includes('main')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Чи є крок з `uses: JS-DevTools/npm-publish` та `with.package` для npm-пакета.
|
|
229
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
230
|
+
* @returns {boolean} `true`, якщо знайдено крок publish з `package: npm/package.json`
|
|
231
|
+
*/
|
|
232
|
+
export function hasNpmPublishStepWithPackage(root) {
|
|
233
|
+
for (const { step } of flattenWorkflowSteps(root)) {
|
|
234
|
+
const uses = getStepUses(step)
|
|
235
|
+
if (uses.includes('JS-DevTools/npm-publish')) {
|
|
236
|
+
const w = step.with
|
|
237
|
+
if (w && typeof w === 'object' && /** @type {Record<string, unknown>} */ (w).package === 'npm/package.json') {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Чи є у job `permissions.id-token: write`.
|
|
247
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
248
|
+
* @returns {boolean} `true`, якщо OIDC-дозвіл для npm publish налаштований
|
|
249
|
+
*/
|
|
250
|
+
export function hasIdTokenWritePermission(root) {
|
|
251
|
+
const jobs = root?.jobs
|
|
252
|
+
if (!jobs || typeof jobs !== 'object') {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
for (const job of Object.values(jobs)) {
|
|
256
|
+
if (job && typeof job === 'object') {
|
|
257
|
+
const perm = /** @type {Record<string, unknown>} */ (job).permissions
|
|
258
|
+
if (perm && typeof perm === 'object' && /** @type {Record<string, unknown>} */ (perm)['id-token'] === 'write') {
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Перевірки для `lint-js.yml`: checkout@v6, persist-credentials, setup-bun-deps, run-команди.
|
|
268
|
+
* @param {Record<string, unknown> | null} root корінь workflow або `null` якщо parse не вдався
|
|
269
|
+
* @returns {{ ok: boolean, failures: string[] }} результат перевірки та список причин відмови
|
|
270
|
+
*/
|
|
271
|
+
export function verifyLintJsWorkflowStructure(root) {
|
|
272
|
+
/** @type {string[]} */
|
|
273
|
+
const failures = []
|
|
274
|
+
if (!root) {
|
|
275
|
+
return { ok: false, failures: ['YAML не вдалося розібрати — перевір синтаксис workflow'] }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const steps = flattenWorkflowSteps(root)
|
|
279
|
+
const usesList = steps.map(s => getStepUses(s.step))
|
|
280
|
+
const runBlob = steps.map(s => getStepRun(s.step)).join('\n')
|
|
281
|
+
|
|
282
|
+
if (!usesList.some(u => u.includes('actions/checkout@v6'))) {
|
|
283
|
+
failures.push('немає кроку uses: actions/checkout@v6')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let checkoutOk = false
|
|
287
|
+
for (const { step } of steps) {
|
|
288
|
+
const u = getStepUses(step)
|
|
289
|
+
if (u.includes('actions/checkout@v6')) {
|
|
290
|
+
const w = step.with
|
|
291
|
+
if (w && typeof w === 'object' && /** @type {Record<string, unknown>} */ (w)['persist-credentials'] === false) {
|
|
292
|
+
checkoutOk = true
|
|
293
|
+
break
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!checkoutOk) {
|
|
298
|
+
failures.push('checkout@v6 без with.persist-credentials: false')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!usesList.some(u => u.includes('./.github/actions/setup-bun-deps'))) {
|
|
302
|
+
failures.push('немає uses: ./.github/actions/setup-bun-deps')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!runBlob.includes('bunx oxlint')) {
|
|
306
|
+
failures.push('у run немає bunx oxlint')
|
|
307
|
+
}
|
|
308
|
+
if (!runBlob.includes('bunx eslint .')) {
|
|
309
|
+
failures.push('у run немає bunx eslint .')
|
|
310
|
+
}
|
|
311
|
+
if (!runBlob.includes('bunx jscpd .')) {
|
|
312
|
+
failures.push('у run немає bunx jscpd .')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const { step } of steps) {
|
|
316
|
+
const run = getStepRun(step)
|
|
317
|
+
if (/bunx\s+oxlint[^\n]*--fix/u.test(run)) {
|
|
318
|
+
failures.push('у run є oxlint з --fix (у CI заборонено)')
|
|
319
|
+
}
|
|
320
|
+
if (run.includes('eslint --fix')) {
|
|
321
|
+
failures.push('у run є eslint --fix (у CI заборонено)')
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Чи є в будь-якому `run` кроку підрядок (наприклад `bun run lint-text`).
|
|
330
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
331
|
+
* @param {string} needle підрядок для пошуку
|
|
332
|
+
* @returns {boolean} `true`, якщо хоча б один `run` містить `needle`
|
|
333
|
+
*/
|
|
334
|
+
export function anyRunStepIncludes(root, needle) {
|
|
335
|
+
for (const { step } of flattenWorkflowSteps(root)) {
|
|
336
|
+
if (getStepRun(step).includes(needle)) {
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Чи є в будь-якому `run` підрядок `stylelint`.
|
|
345
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
346
|
+
* @returns {boolean} `true`, якщо stylelint згадано в команді
|
|
347
|
+
*/
|
|
348
|
+
export function anyRunStepIncludesStylelint(root) {
|
|
349
|
+
return anyRunStepIncludes(root, 'stylelint')
|
|
350
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Визначає явні імпорти з модуля `vue`, які суперечать vue.mdc (має працювати unplugin-auto-import).
|
|
3
|
+
*
|
|
4
|
+
* Аналіз import виконується через **oxc-parser** (`parseSync`, поле `module.staticImports`) — ESTree-сумісний
|
|
5
|
+
* розбір без евристик по рядках. Дозволені лише side-effect `import 'vue'`, повністю type-only імпорти
|
|
6
|
+
* та `import { type A, type B } from 'vue'` (перевірка `entries[].isType`).
|
|
7
|
+
*
|
|
8
|
+
* Для `.vue` з шаблону витягуються лише теги `<script>` / `<script setup>` (регулярний вираз); далі той самий Oxc-парсинг
|
|
9
|
+
* вмісту скрипта з віртуальним ім’ям `*.ts` для режиму TypeScript.
|
|
10
|
+
*/
|
|
11
|
+
import { parseSync } from 'oxc-parser'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Мова для Oxc за шляхом файлу (розширення).
|
|
15
|
+
* @param {string} filePath віртуальний або реальний шлях до файлу
|
|
16
|
+
* @returns {'js' | 'jsx' | 'ts' | 'tsx'} значення опції `lang` для `parseSync`
|
|
17
|
+
*/
|
|
18
|
+
function langFromPath(filePath) {
|
|
19
|
+
const lower = filePath.toLowerCase()
|
|
20
|
+
if (lower.endsWith('.tsx')) {
|
|
21
|
+
return 'tsx'
|
|
22
|
+
}
|
|
23
|
+
if (lower.endsWith('.ts') || lower.endsWith('.mts') || lower.endsWith('.cts')) {
|
|
24
|
+
return 'ts'
|
|
25
|
+
}
|
|
26
|
+
if (lower.endsWith('.jsx')) {
|
|
27
|
+
return 'jsx'
|
|
28
|
+
}
|
|
29
|
+
return 'js'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Номер рядка (1-based) за зміщенням у буфері.
|
|
34
|
+
* @param {string} content повний текст файлу
|
|
35
|
+
* @param {number} offset байтове зміщення початку import
|
|
36
|
+
* @returns {number} номер рядка від 1
|
|
37
|
+
*/
|
|
38
|
+
function offsetToLine(content, offset) {
|
|
39
|
+
let line = 1
|
|
40
|
+
const n = Math.min(offset, content.length)
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
if (content.codePointAt(i) === 10) {
|
|
43
|
+
line++
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return line
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Стискає пробіли для повідомлення про порушення.
|
|
51
|
+
* @param {string} s фрагмент коду
|
|
52
|
+
* @returns {string} скорочений однорядковий рядок
|
|
53
|
+
*/
|
|
54
|
+
function normalizeSnippet(s) {
|
|
55
|
+
return s.replaceAll(/\s+/g, ' ').trim().slice(0, 160)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Чи цей static import з `vue` дозволено правилом (усі записи type-only або порожній side-effect).
|
|
60
|
+
* @param {{ moduleRequest: { value: string }, entries: { isType: boolean }[] }} imp запис з `module.staticImports`
|
|
61
|
+
* @returns {boolean} `true`, якщо імпорт дозволено (type-only або `import 'vue'`)
|
|
62
|
+
*/
|
|
63
|
+
function isAllowedVueStaticImport(imp) {
|
|
64
|
+
if (imp.entries.length === 0) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
return imp.entries.every(e => e.isType)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Віртуальний шлях для парсера: вміст з `<script>` у `.vue` розбираємо як TypeScript.
|
|
72
|
+
* @param {string} relativePath шлях до файлу в пакеті
|
|
73
|
+
* @returns {string} той самий шлях або з `.vue` заміненим на `.ts`
|
|
74
|
+
*/
|
|
75
|
+
function virtualPathForParse(relativePath) {
|
|
76
|
+
if (relativePath.endsWith('.vue')) {
|
|
77
|
+
return relativePath.replace(/\.vue$/u, '.ts')
|
|
78
|
+
}
|
|
79
|
+
return relativePath
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Витягує з SFC лише код усередині `<script>`, щоб не чіпати шаблон.
|
|
84
|
+
* @param {string} sfc вміст .vue файлу
|
|
85
|
+
* @returns {string} текст усередині тегів `<script>` (усі блоки поспіль)
|
|
86
|
+
*/
|
|
87
|
+
export function extractVueScriptBlocks(sfc) {
|
|
88
|
+
const chunks = []
|
|
89
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi
|
|
90
|
+
let m = re.exec(sfc)
|
|
91
|
+
while (m) {
|
|
92
|
+
chunks.push(m[1])
|
|
93
|
+
m = re.exec(sfc)
|
|
94
|
+
}
|
|
95
|
+
return chunks.join('\n\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Підбирає текст для сканування: для .vue — лише script-блоки, інакше — увесь вміст.
|
|
100
|
+
* @param {string} content сирий вміст файлу
|
|
101
|
+
* @param {string} filePath відносний шлях (для вибору режиму)
|
|
102
|
+
* @returns {string} текст для `parseSync`
|
|
103
|
+
*/
|
|
104
|
+
export function contentForVueImportScan(content, filePath) {
|
|
105
|
+
if (filePath.endsWith('.vue')) {
|
|
106
|
+
return extractVueScriptBlocks(content)
|
|
107
|
+
}
|
|
108
|
+
return content
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Знаходить заборонені static import з `vue` у вже підготовленому тексті (без `<template>`).
|
|
113
|
+
* Використовує **oxc-parser**; при синтаксичних помилках повертає порожній масив (спочатку виправ синтаксис).
|
|
114
|
+
* @param {string} content вихідний код
|
|
115
|
+
* @param {string} [virtualPath] шлях для вибору `lang` (наприклад `app/src/foo.ts` або віртуальний після `.vue` → `.ts`)
|
|
116
|
+
* @returns {{ line: number, snippet: string }[]} список порушень з номером рядка початку import
|
|
117
|
+
*/
|
|
118
|
+
export function findForbiddenVueImportsInText(content, virtualPath = 'scan.ts') {
|
|
119
|
+
const pathForLang = virtualPath || 'scan.ts'
|
|
120
|
+
const lang = langFromPath(pathForLang)
|
|
121
|
+
let result
|
|
122
|
+
try {
|
|
123
|
+
result = parseSync(pathForLang, content, { lang, sourceType: 'module' })
|
|
124
|
+
} catch {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
if (result.errors?.length) {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
131
|
+
const out = []
|
|
132
|
+
for (const imp of result.module.staticImports) {
|
|
133
|
+
if (imp.moduleRequest.value === 'vue' && !isAllowedVueStaticImport(imp)) {
|
|
134
|
+
out.push({
|
|
135
|
+
line: offsetToLine(content, imp.start),
|
|
136
|
+
snippet: normalizeSnippet(content.slice(imp.start, imp.end))
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Чи слід пропустити файл під час обходу пакета (генерація, типи).
|
|
145
|
+
* @param {string} relativePosix шлях з posix-слешами
|
|
146
|
+
* @returns {boolean} `true`, якщо файл не сканувати (`.d.ts`, згенеровані імена)
|
|
147
|
+
*/
|
|
148
|
+
export function shouldSkipFileForVueImportScan(relativePosix) {
|
|
149
|
+
const base = relativePosix.split('/').pop() || ''
|
|
150
|
+
if (base === 'auto-imports.d.ts' || base === 'components.d.ts') {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
if (relativePosix.endsWith('.d.ts')) {
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Чи сканувати цей файл за розширенням.
|
|
161
|
+
* @param {string} relativePath відносний шлях до файлу
|
|
162
|
+
* @returns {boolean} `true`, якщо розширення підходить для пошуку import
|
|
163
|
+
*/
|
|
164
|
+
export function isVueImportScanSourceFile(relativePath) {
|
|
165
|
+
return /\.(vue|[cm]?[jt]sx?)$/.test(relativePath)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Знаходить порушення в одному файлі (з урахуванням .vue script extraction).
|
|
170
|
+
* @param {string} content сирий вміст файлу
|
|
171
|
+
* @param {string} relativePath шлях відносно кореня пакета або репо
|
|
172
|
+
* @returns {{ line: number, snippet: string }[]} список порушень для цього файлу
|
|
173
|
+
*/
|
|
174
|
+
export function findForbiddenVueImportsInSourceFile(content, relativePath) {
|
|
175
|
+
const scan = contentForVueImportScan(content, relativePath)
|
|
176
|
+
const virtualPath = virtualPathForParse(relativePath)
|
|
177
|
+
return findForbiddenVueImportsInText(scan, virtualPath)
|
|
178
|
+
}
|